← Back to the section

You logged in through Keycloak, got back a JSON with three long strings — access_token, id_token, refresh_token — and that is where the confusion begins. Which of them do you put into a request to your own API? Can you send the id_token there? And where does the refresh_token go? On top of that, somewhere you read about "opaque tokens" and "introspection" — is that a fourth token? Let's put everything on its shelf so you never mix them up again.

The most important thing to grasp from the very start: there are two different axes here, and people constantly blur them together.

  • Axis 1 — the token's role. This is about why the token exists and who it is addressed to. Three tokens — three roles. Each flies to its own place.
  • Axis 2 — the access token's format. This is about what the access_token looks like inside and how it is verified. Here there are two options — JWT and opaque. This is not another token, but two forms of the very same access_token.

If you don't separate these axes, you end up with a mess like "I have four tokens and I don't understand which one is failing again." Let's separate them.

Axis 1: three tokens — three roles

When a client (a frontend or a mobile app) exchanges the authorization code for tokens, Keycloak replies with roughly this JSON:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI...",
  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI...",
  "token_type": "Bearer",
  "expires_in": 300,
  "refresh_expires_in": 1800,
  "scope": "openid profile email"
}

Three tokens — and each has its own job, its own destination. Mixing them up is the most common beginner mistake. Let's go through them one at a time.

access_token — the pass to your API

The access_token is the "pass to the data." It answers the question "what is this request allowed to do." It — and only it — is what the client attaches to every request to your backend, in the header:

Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI...

Memorize this firmly: the access_token is the only token your API ever sees. Your backend does not know and does not need to know about the id_token and refresh_token. For it, the entire world of tokens is one string in the Authorization: Bearer ... header, and that string always holds the access_token.

The word Bearer means exactly that — "the one who carries it": whoever brought the token is treated as its owner. That is why token storage is taken seriously (more on this in the mistakes section). The access_token lives a short life — usually a few minutes (expires_in above is 300 seconds, that is 5 minutes).

id_token — who logged in, for the client itself

The id_token answers a different question — "who is this user." Inside it are the details of the person who logged in: their identifier, name, e-mail. These details are called claims. A typical set:

  • sub — the immutable user identifier;
  • name — the display name;
  • email — the e-mail;
  • preferred_username — the login.

Who needs this? The client itself — the frontend or the mobile app. To write "Hi, Ivan" in the corner of the page and show an avatar, the app needs to know who logged in. That is exactly what the id_token exists for: it is an "identity card" for whoever requested the login.

And here comes the main rule that many people came here for:

The id_token is never sent to the API. Ever.

A very common beginner mistake: you got two similar-looking tokens, grabbed the first one you saw (often the id_token, actually) and put it into Authorization: Bearer .... On the surface the strings look alike, both are long JWTs. But the id_token was invented not for API access but for identifying the user on the client side. A properly configured backend will reject such a token (for example, because the id_token has a different audience — it is issued for the client, not for your API). The Authorization header carries the access_token and nothing else.

refresh_token — the renewal ticket, only for Keycloak

The access_token lives five minutes. So does the user re-enter their password every five minutes? Of course not. For that there is the refresh_token — the "renewal ticket."

When the access_token expires, the client does not go back to the user for a password; instead it sends the refresh_token back to Keycloak, to the token endpoint, and gets a fresh access_token (and usually a new refresh_token as well):

POST https://keycloak.example.com/realms/shop/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&client_id=web-app
&refresh_token=<saved refresh_token>

The key point about the destination: the refresh_token flies only to Keycloak, to its /token endpoint, and nowhere else. It never reaches your API — your backend does not need the refresh_token and must not see it. It is a "secret ticket" between the client and Keycloak.

Since the refresh_token lives a long time (hours or days) and can be used to obtain new access tokens, its theft is more dangerous. So it must be stored carefully: in an httpOnly cookie (invisible to a script on the page) or on the backend side entirely — but not in the browser's localStorage, from which the first hostile script will grab it. We'll come back to this mistake.

All three on one diagram

What the diagram shows: after login, each of the three tokens flies to its own place — and these places do not overlap.

diagram

Read the diagram as the answer to the original confusion:

  • access_token goes to your API (Authorization: Bearer). The only token the API sees.
  • id_token stays with the client and is used to show who logged in. It does not go to the API.
  • refresh_token goes back to Keycloak at /token to get a new access token. It does not go to the API.

To boil it all down to one sentence: there is one Authorization header — and it always holds the access_token; the id_token and refresh_token are never put there.

Axis 2: what the access_token itself can be — JWT or opaque

Now that the roles are separated, let's look inside the access_token itself. This is where "opaque vs JWT" appears. Let's stress it once more to clear up the confusion: this is not a fourth token or a fifth one. These are two possible formats of the very same access_token. The Authorization: Bearer ... header carries the access_token in both cases — only its internal structure and the way the backend verifies it change.

JWT format (by-value): self-contained, verified locally

By default Keycloak issues the access_token in JWT (JSON Web Token) format. This is a "self-contained" token: inside it already lie all the needed data (who the user is, what roles they have, until when it is valid), and on top there is Keycloak's cryptographic signature.

"By-value" means "by the value": everything needed is in the token itself. The backend does not have to go anywhere to find out who this is — it simply reads the token's contents and verifies the signature.

Signature verification works like this. Keycloak signs the token with its private key, known only to Keycloak. And it hands out the public key for verification to everyone at a special address — JWKS (JSON Web Key Set):

https://keycloak.example.com/realms/shop/protocol/openid-connect/certs

Then the backend:

  1. downloads the public keys from the JWKS address once and keeps them in memory;
  2. on each incoming request takes the signature from the token and verifies it with that key — locally, without contacting Keycloak;
  3. the signature matches — the token is genuine; it doesn't match — the request is rejected.

The main upside: on each request there is no network call to Keycloak at all — the keys are already in memory, verification is instant and does not depend on whether Keycloak is alive right now. This is that "offline verification."

The downside is the flip side of the same coin: since the backend verifies everything itself and does not contact Keycloak, it will not learn instantly that a token was revoked. A revoked JWT keeps working until its expiration time (exp). That is why the access_token is made short — the window of "the token is revoked but still works" comes out small.

Opaque format (by-reference): a random string, verified in Keycloak

The alternative is the opaque token (non-transparent). It is just a random string with no content inside: nothing can be read from it directly, it is merely a "reference" to a session that Keycloak keeps on its side.

"By-reference" means "by the reference": there is no data in the token, only a pointer. To find out who this is and whether the token is valid, the backend must ask Keycloak — contact the introspection endpoint:

POST https://keycloak.example.com/realms/shop/protocol/openid-connect/token/introspect

In response Keycloak says: the token is active or not, whose it is, what roles it has.

The upside: revocation works instantly — as soon as the session is ended in Keycloak, the very next check returns "invalid." No window like with JWT.

The downside: you pay for it with a network call to Keycloak on every request to the API (in practice the results are usually cached for a short time, but it is still a trip over the network, and if Keycloak is unavailable verification stalls).

Two verification branches on one diagram

What the diagram shows: the backend received the access_token and then validates it differently depending on the format.

diagram

Briefly about the choice: JWT — fast and independent of Keycloak at runtime, but revocation is delayed up to exp. Opaque — instant revocation, but a network call on every request. By default Keycloak uses JWT, and for most services that is enough.

How to configure this in Spring

In Spring, all the verification is handled by the spring-boot-starter-oauth2-resource-server starter — you don't need to and shouldn't unpack tokens by hand (a homemade filter is easy to make leaky). Important: two formats — two different configurations, and they are mutually exclusive. You pick one of the two, not both at once.

JWT mode — local verification by signature (what you need in most cases):

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://keycloak.example.com/realms/shop

From the issuer-uri Spring will find the JWKS address itself (via .well-known/openid-configuration), download the keys and verify each token locally: the signature, the expiration and the issuer match. If you want to set the key address directly (Keycloak behind a proxy, a non-standard path), instead of issuer-uri you specify jwk-set-uri.

Opaque mode — verification via introspection (when you need instant revocation):

spring:
  security:
    oauth2:
      resourceserver:
        opaquetoken:
          introspection-uri: https://keycloak.example.com/realms/shop/protocol/openid-connect/token/introspect
          client-id: my-api
          client-secret: ${INTROSPECTION_CLIENT_SECRET}

Here Spring calls the introspection address on every request, presenting itself with its client-id/client-secret. Notice: the jwt block has no credentials at all (verification is local and anonymous), while in the opaquetoken block they are mandatory (you need something to authenticate with when contacting Keycloak). This is the vivid difference between the two axes: there is one token — the access_token — but two ways to verify it.

Common mistakes and how to avoid them

Most token problems are not "breaking the cryptography" but confusion about roles and oversights in configuration.

  • Sending the id_token to the API. The most common mistake, caused by the tokens' external resemblance. Authorization: Bearer ... carries only the access_token. The id_token is meant for the client, to show who logged in, and a properly configured backend will reject it.
  • Storing the refresh_token (and any tokens) in localStorage. In the browser's localStorage the token is accessible to any script on the page. One XSS vulnerability (a hostile script on the page) — and the token leaks, and the refresh_token is especially dangerous: it is used to issue new access tokens. It is safer to use an httpOnly cookie (invisible to scripts) or to store it on the backend side.
  • Confusing the verification format. You configured the jwt block but Keycloak returns opaque tokens (or the other way around) — verification will fail. First determine which format your access_token has, then configure the matching mode. You cannot enable both blocks at once.
  • Not checking the issuer (iss) and the audience (aud). Even for a token with a valid signature, you must make sure it was issued by exactly your realm (iss) and that it is intended for exactly your API (aud). Otherwise a valid token from a neighboring service will pass where it shouldn't. In jwt mode Spring checks iss itself; the aud check is usually added separately.
  • Clock skew and expiration. A token is valid for a limited time. If the clocks on the Keycloak server and on the backend drift apart, a fresh token may look "not yet valid" or "already expired," and you get inexplicable 401s. Cured by time synchronization (NTP) and a small time tolerance during verification.
  • Too long an access_token lifetime. It is tempting to set it to a day so you refresh less often. But in JWT format you cannot revoke a token before it expires — a stolen one will live for a day. Keep the access_token short and provide convenience through the refresh_token (and where instant revocation is needed — use opaque).

Above we said: the refresh_token is safer to store in an httpOnly cookie than in localStorage. But "put it in a cookie" is not a single checkbox but several attributes, and each closes its own hole. If you set the cookie sloppily, it protects no better than localStorage. Let's break down what exactly each attribute does and why it is needed there.

The cookie that holds the refresh_token (or a server-side session) is set by the backend roughly like this:

var refreshCookie = ResponseCookie.from("refresh_token", refreshToken)
    .httpOnly(true)
    .secure(true)
    .sameSite("Lax")
    .path("/auth/refresh")
    .maxAge(Duration.ofDays(7))
    .build();

And here is what each line gives you:

AttributeWhy it's needed
HttpOnlyA script on the page cannot read the cookie — document.cookie simply won't return it. This is exactly the XSS protection for which we left localStorage: even if a hostile script gets onto the page, it won't reach the token.
SecureThe cookie is sent only over HTTPS. Without this attribute the browser would send it over plain http too, and the token could be intercepted in the clear over the network (for example, on public Wi-Fi).
SameSite=LaxThe browser won't attach the cookie to a request initiated by a foreign site via a form/POST. This is CSRF protection: a malicious page can't trigger a refresh "on your behalf," because the cookie won't come with its request.
Path=/auth/refreshThe cookie is attached only to requests to this path, not to everything indiscriminately.
Max-Age (or Expires)The cookie's lifetime. Without it the cookie lives until the tab is closed (a session cookie); with an explicit lifetime you control how long the refresh_token is valid on the browser side at all.

A separate word about Path — a nuance that is often missed. If you leave the default path (/), the browser will attach the refresh_token cookie to every request to your domain — including ordinary API calls that don't need the refresh_token at all. The more often a long-lived and dangerous token "shows up" over the network, the higher the chance it settles somewhere — in proxy logs, in a debug panel, in a random request dump. By narrowing the path down to a tight /auth/refresh, you achieve that the refresh_token leaves the browser only when you actually need to renew the access — that is, to one single renewal address and nowhere else.

The sensible principle behind all of this is simple: the more dangerous a token is, the narrower the place where it appears should be. The access_token lives five minutes and is needed on every request — it is in the header anyway. The refresh_token lives for days and is dangerous if stolen — so it is hidden (HttpOnly), allowed only over a secure channel (Secure), not handed to a foreign site (SameSite) and shown to the browser only on one narrow path (Path).

In short

  • Keycloak returns three tokens — and each has its own role and its own destination, they must not be mixed.
  • access_token → to your API, in the Authorization: Bearer header. The only token the API sees.
  • id_token → to the client, to show who logged in (sub, name, email). Never sent to the API.
  • refresh_token → only back to Keycloak at /token for a new access. Does not reach the API; store it safely (httpOnly cookie / on the backend), not in localStorage.
  • There is one Authorization header — and it always holds the access_token.
  • JWT vs opaque — this is the format of the access_token itself, not another token. A JWT is verified locally with keys from JWKS (offline, fast). An opaque token is verified via introspection in Keycloak (online, on every request, but with instant revocation).
  • In Spring these are mutually exclusive modes: jwt (with issuer-uri) or opaquetoken (with introspection-uri + client). One of the two, not both.
  • Common mistakes: id_token to the API; refresh in localStorage; not checking iss/aud; clock skew; confusing the opaque and JWT verification modes.
  • OAuth2 and OIDC in plain words — where the three tokens come from in the first place and how access differs from identifying a person.
  • Authorization Code Flow and PKCE — how exactly the client obtains these tokens: the redirect, the code, exchanging the code for tokens.
  • Keycloak and Spring Security: token verification — configuring the Resource Server, issuer-uri, reading claims from the token.
  • Roles and access: RBAC and ABAC with Keycloak — how roles from the access_token turn into access checks.