Guide: How OAuth refresh tokens & revocation work with GPT Actions

Hello!

I’ve spent the afternoon figuring out how GPT Actions work with OAuth refresh tokens, expiries, and in particular how to do token revocation, since this isn’t documented anywhere. This guide assumes you have already implemented OAuth with non-expiring tokens and are interested in understanding either (a) when ChatGPT revokes those tokens, or (b) how ChatGPT handles expiries. I will not explain how to implement refresh tokens yourself (there are many guides for this).

Why should I use refresh tokens?

Two reasons.

First, ChatGPT cannot programatically revoke a token on demand.

If you experience a data breach or another security issue, there is no current way to log your users out of your GPT Action (there are often more mundane reasons to revoke tokens, too, such as password changes). All of their requests to your GPT actions will fail (assuming you blocked their access :scream:), and you’ll have to ask them to manually sign out and back in again, which is cumbersome. If you use refresh tokens, your user’s tokens will expire after a certain amount of time, at which point you can direct ChatGPT to ask the user to sign in again. If you set a short enough expiry, this means your users will only have to experience a very small amount of downtime without manually signing out.

Second, if OpenAI or ChatGPT experience a data breach, then attackers would only need your token to make authenticated calls against your backend, and siphon your users’ data. With expiring tokens, you require attackers to at least have a refresh token and an expiring secret to issue a new token for your backend, which means they have to act within a time limit rather than indefinitely. (Because OpenAI store everything, likely in the same place, it’s not a huge security improvement but there are benefits nonetheless).

How do I provide a refresh token to ChatGPT?

The docs hint at this, but basically your /oauth/token endpoint can return:

{
    "access_token": "<example access token>",
    "token_type": "Bearer",
    "expires_in": 60,
    "refresh_token": "<example refresh token>"
}

ChatGPT looks for the expires_in field (not expires) for the number of seconds before the token expires (this one will expire in 1 minute), and a refresh_token field. Obviously, return a different refresh token from your access token.

See below for what to set expires_in to.

How does ChatGPT refresh my tokens?

ChatGPT does not pre-emptively refresh tokens. Instead, the next time your user makes a request that involves an action, ChatGPT searches its secret store for your access token, and if the expiry date has passed, it will call /oauth/token again with the following payload:

(If you’ve implemented OAuth so far with GPT Actions you’ll remember that this payload has Content-Type: application/x-www-form-urlencoded and should be parsed as such; I’m representing it in JSON because it’s easier to understand)

{
    "client_id": "<your client ID>",
    "client_secret": "<your client secret>",
    "grant_type": "refresh_token",
    "refresh_token": "<your refresh token from before>"
}

It’s your responsibility to validate the client secret and refresh token. You can then respond with:

{
    "access_token": "<example access token>",
    "token_type": "Bearer"
}

Alternatively, if you respond with any 401 error (no need to worry about content types, headers, or anything else), ChatGPT will remove any of that user’s tokens from the storage and prompt them to sign in again. More on that in a moment.

How can I refresh my refresh tokens?

It’s usually best practice to:

  1. Delete all refresh tokens after a single use
  2. Expire your refresh tokens after a certain amount of time

However, since ChatGPT is securely storing both tokens, it might not be a requirement of your security model. But in case you want to do this, ChatGPT supports adding the expires_in and refresh_token parameters to the above response, like so:

{
    "access_token": "<example access token>",
    "token_type": "Bearer",
    "expires_in": 60,
    "refresh_token": "<new example refresh token>"
}

In the background, you would delete the refresh token the user has just used, and issue a new one. If ChatGPT doesn’t contact your server in time, you would expire the old token so that it can’t be used.

Just like above, if you respond with 401, then ChatGPT will delete your user’s refresh token and prompt the user to sign in again.

How can I revoke an existing access or refresh token?

Firstly, you can’t revoke a token during a normal request. If ChatGPT requests your /api/do_cool_thing, and you reply with the standard HTTP Auth 401 (read more on this):

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The access token expired"

Nothing will happen. ChatGPT does not, as of time of writing, revoke your access token in response and prompt your user to sign in. The only time ChatGPT will revoke an access token is if it has expired.

You can trigger ChatGPT to prompt the user to sign in again by responding to /oauth/token with any 401 response. This will delete any stored access or refresh tokens from their storage.

As such, the best way to implement a revocation flow is:

  1. Immediately start 401ing your API routes
  2. Wait until the token expires
  3. ChatGPT will contact /oauth/token with the refresh token
  4. You respond with a 401
  5. ChatGPT will delete the token and prompt the user to sign in again

What should I set my token expiries to be?

It makes the most sense to set a short expiry on your access tokens (in the order of minutes), and a longer expiry on your refresh tokens (in the order of days).

The access token expiry can be thought of as:

  1. The minimum amount of time between requests to /oauth/token for refresh tokens
  2. The maximum amount of time your users will have to wait if you expire their access tokens before ChatGPT prompts them to sign in again

The first is a lower bound, because you presumably don’t want to overload your systems with requests (or you might have caches), and the second is an upper bound, because you don’t want your users to experience confusing access errors and possibly churn from your product. If you plan on revoking your tokens very rarely, then this bound can be larger, but if you plan on users experiencing it frequently (i.e. from logging out), then you should set it lower.

The refresh token expiry can be thought of as the maximum amount of time your user can go without calling one of your actions before you sign them out. (The lower bound on your refresh token expiries would be the access token expiry; any lower and you’ll force the user to sign in again every time their access token expires). As ChatGPT has 100 million weekly active users, if you expect your users to use your GPT regularly you might set this as low a day or a week; typical refresh tokens usually expire within a month.

Remember to revoke refresh tokens as soon as they’re used, so that they can’t be re-used later. ChatGPT will always make requests using the latest refresh token.

How can my users revoke their access tokens?

Your users (or you, in development), can manually revoke your access tokens by signing out of the third party service. You’ll need to go to your GPT in the ChatGPT web app (no mobile support as of time of writing).

Start by clicking on the GPT’s name:

Then open ‘Privacy settings’ and click ‘Connected accounts’ in the sidebar. If you can’t see ‘Privacy settings’ or ‘Connected accounts’, that’s most likely because you’ve only been using the GPT via the editor. You’ll need to edit your GPT and publish the latest changes in some form. If you still can’t see it, try triggering an action from outside of the editor and it should sync everything up.

Then click ‘Log out’. The UI won’t update immediately but it will work.

Parting thoughts

If you’re serious about building GPT Actions with user-specific properties, you shouldn’t launch without refresh tokens. While the docs are a bit lacking, hopefully this guide clears some of that up.

Auth is a precise art, and there’s lots of easy footguns. I’ve deliberately avoided implementation details and code here because you shouldn’t copy and paste security-related code, you should be thinking precisely and carefully about what your requirements are and what will happen. That said, any security is better than no security.

Please ask any questions below or let me know if I’ve made mistakes.

13 Likes

You’re awesome, thanks for writing this.

1 Like

I’ve successfully implemented the initial OAuth flow for my Custom GPT actions, and during the first request, OpenAI correctly includes the ‘client_id’ and ‘client_secret` as given to it in the OAuth UI. However, I’ve encountered an issue during token refresh. After the access token expires, OpenAI sends a request to my ‘Token URL’ with ‘grant_type’: ‘refresh_token.’ Unfortunately, this refresh request appears to contain incorrect ‘client_id’ and ‘client_secret,’ causing it to fail.

Huh. Without giving them away, how different are they from yours? Same character set? Same structure? (How likely is it that this is a hallucination, a bug, a security leak, something else?)

It’s just altogether a different UUID, nothing I can find in my codebase or otherwise. Also - they both have the same value. Really feels like this leaked from someone else’s account or something O_O

Unusual! What happens if you prepend your client ID and secret with a string, like ayalgelles_secret_? If it spits out the same format it’s definitely hallucinating, but if it sends the previous IDs something weirder is going on.

Don’t delete and re-create anything just yet in case it’s a security issue—it’ll be worth keeping things around so you can report it :slight_smile:

Spent a while trying to find this info. Super helpful. Thanks for sharing!

Agreed, this was extremely helpful post!

Some other struggles along the OAuth path, it definitely seems to go off the rails from time to time. Especially if you modify your open api spec and the editor throws an error message.

Anyone getting stuck - Re-configure your OAuth even if it was set up correctly before. Exit the editor and come back in to get the new redirect URI. Reconfigure your client app with the new redirect URI. I had to do that several times for no apparent reason along the process.