An access token is a short-lived credential that an API accepts as proof of what the caller may do, while a refresh token is a longer-lived credential that can only be sent to the authorization server to get new tokens. The two serve different purposes and are meant to be accepted by different endpoints, so a server that issues both must also check which kind it was handed. Gitea’s OAuth2 provider failed to do that check until version 28.1.0, which is a useful real example of why.
This guide compares the two token types side by side, walks through what the Gitea fix changed (verified against Gitea’s source), and lists what to check in your own Keycloak realm and applications.
What is the difference between an access token and a refresh token?
The OAuth 2.0 framework (RFC 6749) defines both. An access token is presented to the resource server (your API) on each request, usually in an Authorization: Bearer header. A refresh token is presented only to the authorization server’s token endpoint, using the refresh_token grant, to obtain a new access token once the old one has expired. RFC 6749 section 1.5 describes refresh tokens as credentials used to obtain access tokens, and says they are issued to the client by the authorization server but are never sent to the resource server.
| Access token | Refresh token | |
|---|---|---|
| Purpose | Authorizes a request to an API | Obtains new tokens |
| Presented to | Resource server (API) | Authorization server (token endpoint) |
| Typical lifetime | Minutes to an hour | Hours to weeks, or until the session ends |
| Sent how often | On every API call | Only when the access token expires |
| Format | Often a JWT, can be opaque | Often opaque, can be a JWT |
| Exposure if leaked | Usable until it expires | Can mint new access tokens for as long as it stays valid |
| Where to keep it | Client memory or a short-lived cookie | Server-side storage, or an HttpOnly cookie, never localStorage |
An ID token, the third token in an OpenID Connect login, is a different thing again: it tells the application who the user is and is not meant to be sent to an API. Our guide to ID token vs access token covers that comparison.
The lifetimes are the reason refresh tokens exist. Keeping access tokens short limits the damage when one leaks, and the refresh token lets the application keep the user signed in without asking for a password again. That arrangement only holds if the refresh token is protected more carefully than the access token and if the two cannot be swapped for each other.
What went wrong in Gitea’s OAuth2 refresh endpoint?
Gitea can act as an OAuth2 provider. Its tokens are signed JWTs that carry a tt claim recording the token kind: KindAccessToken (value 0) or KindRefreshToken (value 1). In Gitea 28.0.0, the handler for the refresh_token grant parsed the submitted token, which checks the signature and expiry, and then looked up the grant it belonged to. It did not look at the kind. Access tokens and refresh tokens are signed with the same server key and refer to the same grant, so an access token passed the checks.
The fix is one line in routers/web/auth/oauth2_provider.go, shipped in the commit “fix: add missing checks to several API and web handlers” (backported on 30 September 2026 and included in v28.1.0):
// before
token, err := oauth2_provider.ParseToken(form.RefreshToken, serverKey)
if err != nil {
// after
token, err := oauth2_provider.ParseToken(form.RefreshToken, serverKey)
if err != nil || token.Kind != oauth2_provider.KindRefreshToken {
That commit lists the change among several as “Refresh token grant only accepts refresh tokens”. We have not found a published advisory with a confirmed CVE identifier or severity rating, so this article describes only the code change.
Why this mattered in practice
Gitea’s default settings make the effect easy to see. Its defaults, from modules/setting/oauth2.go, are an access token lifetime of 3600 seconds and a refresh token lifetime of 730 hours, which is about 30 days. In the vulnerable version, a client holding an access token that was valid for another few minutes could present it to the refresh endpoint and receive a brand new access token and a new refresh token valid for about 30 days. In effect, an access token meant to last an hour could be turned into access that lasted about a month.
Two conditions limited it. The caller still had to pass client authentication, so confidential clients needed their client secret, though public clients have none. And the rotation counter that Gitea can use to invalidate refresh tokens (INVALIDATE_REFRESH_TOKENS, off by default) would have rejected an access token anyway, because access tokens carry no counter. Installations with that option on were therefore protected by a side effect rather than by an explicit check.
Why is checking the signature not enough?
A valid signature proves that a token was issued by the server and has not been changed. It does not prove the token is the right kind for the endpoint that received it. When one signing key covers several token types, the type has to be checked separately, in the same way that an API should check the aud (audience) claim rather than accept any validly signed token. The pattern shows up in several forms:
- A refresh endpoint accepting an access token, as in Gitea.
- An API accepting an ID token as an access token, which we cover in the ID token vs access token post.
- A JWT validator that accepts tokens meant for a different audience or issuer, discussed in JWT best practices for developers.
In each case the fix is to declare which kind of token each endpoint accepts and reject the rest explicitly.
How does Keycloak handle token kind?
Keycloak tokens carry a typ claim: Bearer for access tokens, ID for ID tokens, Refresh for refresh tokens and Offline for offline tokens. In Keycloak 26.8.1’s TokenManager, the refresh path checks that the submitted token’s type is Refresh or Offline and otherwise throws an invalid_grant error with the message “Invalid refresh token”. So the Gitea-style mix-up is rejected there.
You can confirm this in your own realm in a few minutes, on a test client. Request a token pair, then send the access token as the refresh_token parameter to the token endpoint and expect an invalid_grant response. It is worth running the same test against any other authorization server or custom token endpoint in your stack.
Keycloak has several realm settings that govern how refresh tokens behave, and they are worth reviewing alongside the type check:
- Revoke Refresh Token (realm settings, Tokens tab) makes each refresh token single-use, so each refresh token works once, and whichever party presents it second is rejected, which shows up as an
INVALID_TOKENevent. Our refresh token rotation guide explains the settings and the trade-offs for concurrent requests. - Access Token Lifespan controls how long a leaked access token stays useful. Shorter is safer, at the cost of more refresh calls.
- Offline tokens outlive the user’s browser session by design, so grant the
offline_accessscope only to clients that need it.
What should your application do with each token?
On the application side, a few habits cover most of the risk:
- Store refresh tokens separately from access tokens, and keep them out of places that scripts can read. For browser apps, a backend-for-frontend pattern with an
HttpOnlycookie keeps the refresh token off the page entirely. - Never log raw tokens. Log a hash or the last few characters if you need to correlate events.
- Revoke on logout and on suspected compromise, rather than waiting for expiry. Revoking the session at the authorization server also invalidates the refresh tokens attached to it.
- Check the token kind in your own code, if you validate tokens yourself. Reject an ID token or refresh token that arrives at an API, and reject an access token that arrives where a refresh token is expected.
- Prefer scoped, audience-restricted access tokens so that a leaked one cannot be replayed against unrelated APIs. See API authentication best practices and our write-up on OAuth grant sprawl for how to keep track of what has been granted.
If your sessions are being stolen from user machines, as covered in session hijacking and infostealers, shorter access tokens and refresh token rotation reduce how long a stolen credential stays useful.
Frequently asked questions
Can an access token be used as a refresh token?
It should not be. Each token type is meant for one endpoint, and a correct authorization server rejects an access token sent to the token endpoint’s refresh_token grant. Gitea versions before 28.1.0 did not make that check, which is why it was fixed.
How long should a refresh token last?
It depends on how long you want a user to stay signed in without re-authenticating. Many deployments tie the refresh token to the SSO session, so it ends when the session idles out or the maximum session lifetime passes. Pair a long-lived refresh token with rotation so that a stolen copy has a short useful life.
Where should I store a refresh token?
Server-side where possible, or in an HttpOnly, Secure cookie, and not in localStorage or sessionStorage, which any script on the page can read.
Do I need a refresh token for machine-to-machine access?
Usually not. A service using the client credentials grant can simply request a new access token with its own credentials when the old one expires, and the OAuth specification advises against issuing a refresh token for that grant.
Is a refresh token a JWT?
It can be, as in Gitea, or it can be an opaque string that only the authorization server understands. Clients should treat it as opaque either way, since only the authorization server needs to read it.
What is refresh token reuse detection?
When refresh tokens are single-use, the server remembers that a token has been spent. If the same token arrives a second time, it means either a client bug or a theft, and some servers revoke the whole token family. Keycloak’s Revoke Refresh Token rejects the reused token and records an INVALID_TOKEN event, which you can alert on, but it does not end the session by itself.
Summary
Access tokens authorize API calls for a short time, and refresh tokens obtain new tokens for a longer one, so each endpoint should accept only the kind it is designed for. Gitea’s fix in 28.1.0 shows what happens when the check is missing: a one-hour credential can be traded for a 30-day one. Test your own token endpoints with the wrong kind of token, enable refresh token rotation, keep access token lifetimes short, and keep refresh tokens out of browser-readable storage.