Last updated: July 2026
To verify a Keycloak-issued access token on the backend, you validate three things: the signature (against the realm’s public keys from the JWKS endpoint /realms/{realm}/protocol/openid-connect/certs), the temporal claims (exp, iat, nbf, with a small clock-skew tolerance), and the identity claims (iss matches your realm URL, aud or azp matches your client). Do it locally with a JWT library for speed, or call the token introspection endpoint when you need real-time revocation status. This guide walks through both, with working code for Java, Node.js, and Python.
What’s inside a Keycloak access token?
By default, a Keycloak access token is a signed JSON Web Token as defined in RFC 7519. It has three base64url-encoded parts separated by dots: a header, a payload, and a signature. Everything your backend needs to verify the token is derived from those three parts plus one HTTP endpoint on your Keycloak server.
The header tells you how the token was signed. For a stock Keycloak realm that means "alg": "RS256" (an asymmetric RSA signature) and a kid, the key ID. Realms can hold multiple key pairs at once, especially during rotation, so your verifier uses the kid to pick the right public key.
The payload carries the claims. The ones your backend should care about:
| Claim | What it is | Why you check it |
|---|---|---|
iss |
Issuer, e.g. https://keycloak.example.com/realms/myrealm |
Token came from your realm, not another one on the same server |
exp / iat / nbf |
Expiry, issued-at, not-before timestamps | Rejects expired or not-yet-valid tokens |
aud |
Intended audience | Token was meant for your API |
azp |
Authorized party (requesting client) | Backup check when aud isn’t populated |
sub |
Subject, the user’s unique ID | Who the request acts as |
realm_access / resource_access |
Realm and client roles | Authorization decisions |
The signature makes the rest trustworthy. Keycloak signs the header and payload with the realm’s private key. Anyone can decode a JWT (it’s base64, not encryption), but only Keycloak can produce a signature that verifies against the realm’s public key. Change one byte of the payload and verification fails.
Want to poke at a real token while you read? Paste one into our free JWT Token Analyzer; it decodes the header and payload without sending the token anywhere.
How does local token validation work?
Local validation means your backend verifies the token entirely on its own: no call to Keycloak on the request path. It needs the realm’s public keys once, then every check is pure CPU work measured in microseconds. It’s the default approach for APIs and microservices, and the official Keycloak securing applications guide is built around this model.
Step 1: Fetch the realm’s public keys (JWKS)
Keycloak publishes its public keys as a JSON Web Key Set (RFC 7517) at a well-known URL:
GET /realms/{realm}/protocol/openid-connect/certs
So for a realm called myrealm:
curl https://keycloak.example.com/realms/myrealm/protocol/openid-connect/certs
The response is a keys array, each entry carrying a kid, the algorithm, and the RSA public key material. Note the path: modern Keycloak serves everything under /realms/. If you see /auth/realms/ in a tutorial, it’s written for the pre-Quarkus era (Keycloak 16 and older) and the paths won’t resolve on a current server unless someone explicitly set --http-relative-path=/auth.
Your JWT library fetches this document once, caches it, and reuses the keys for every verification.
Step 2: Match the kid and verify the signature
For each incoming token, the library reads the kid from the token header, finds the matching key in the cached JWKS, and verifies the RS256 signature. Two rules keep this safe:
- Pin the algorithm in your code. Tell the library to accept RS256 (or whatever your realm actually uses) and nothing else. Never let the token’s own header decide which algorithm to verify with; that’s the root of the classic
alg: noneand RS256-to-HS256 confusion attacks. - Handle unknown kids by refetching. When Keycloak rotates its signing key, new tokens carry a new
kidthat isn’t in your cache yet. Good libraries automatically refetch the JWKS on a kid miss, with a cooldown so an attacker can’t make you hammer the endpoint. This is why you point at the JWKS URL instead of copy-pasting a key from the admin console.
Step 3: Validate the claims
A valid signature only proves Keycloak issued the token at some point, for someone. The claims prove it’s valid now, for you:
expis in the future andnbf(if present) is in the past. Allow a small leeway, 5 to 30 seconds, for clock skew between servers.issexactly matches your realm URL, including scheme and port.https://keycloak.example.com/realms/myrealmandhttp://keycloak:8080/realms/myrealmare different issuers even if they’re the same server.audcontains your API’s identifier, orazpmatches an expected client. One Keycloak quirk: out of the box,audis often just"account", not your API’s client ID. The fix is an Audience protocol mapper, covered in the mistakes section below.
That’s the whole algorithm. For a deeper look at how these checks fit into API gateway and microservice architectures, see our guide to Keycloak token validation for APIs.
How do you verify tokens in Java, Node, and Python?
You almost never implement the steps above by hand. Every mainstream ecosystem has a mature library that does JWKS fetching, kid matching, signature verification, and claim validation in a few lines. Here’s the idiomatic setup in each.
Java: Spring Security OAuth2 Resource Server
Spring Boot has first-class support for exactly this job. Add one dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
Then point it at your realm in application.yml:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://keycloak.example.com/realms/myrealm
That single issuer-uri property does a lot: on startup, Spring calls the realm’s OIDC discovery document, finds the JWKS endpoint, and configures a JwtDecoder that verifies signatures and validates iss and exp on every request. Wire it into your security filter chain:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
}
Spring validates the issuer and timestamps by default but not the audience. To enforce aud, register a custom decoder with an extra validator:
@Bean
JwtDecoder jwtDecoder(
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer) {
NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(issuer);
OAuth2TokenValidator<Jwt> defaults = JwtValidators.createDefaultWithIssuer(issuer);
OAuth2TokenValidator<Jwt> audience = new JwtClaimValidator<List<String>>(
"aud", aud -> aud != null && aud.contains("my-api"));
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(defaults, audience));
return decoder;
}
Resist the urge to hand-roll Nimbus JOSE code directly. The starter handles key caching, rotation refetch, and clock skew for you, and the validator hook covers custom claims cleanly.
Node.js: jose
For Node, use jose. It’s the modern standard, ships zero dependencies, and its createRemoteJWKSet handles caching plus automatic refetch when it sees an unknown kid. Here’s a complete Express middleware:
import express from 'express';
import { createRemoteJWKSet, jwtVerify } from 'jose';
const ISSUER = 'https://keycloak.example.com/realms/myrealm';
const JWKS = createRemoteJWKSet(
new URL(`${ISSUER}/protocol/openid-connect/certs`)
);
async function requireAuth(req, res, next) {
const header = req.headers.authorization ?? '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) {
return res.status(401).json({ error: 'missing bearer token' });
}
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: ISSUER,
audience: 'my-api',
algorithms: ['RS256'],
clockTolerance: '5s',
});
req.user = payload;
next();
} catch (err) {
return res.status(401).json({ error: 'invalid token' });
}
}
const app = express();
app.get('/api/orders', requireAuth, (req, res) => {
res.json({ userId: req.user.sub, roles: req.user.realm_access?.roles ?? [] });
});
app.listen(3000);
Notice that issuer, audience, and algorithms are all pinned in code, so a token from the wrong realm or with a tampered header fails before your handler runs. (The older jsonwebtoken plus jwks-rsa combo still works if you’re already on it; jose is the better default for new code.)
Python: PyJWT with PyJWKClient
PyJWT ships a PyJWKClient that does the JWKS fetch and kid matching for you:
import jwt
from jwt import PyJWKClient
ISSUER = "https://keycloak.example.com/realms/myrealm"
JWKS_URL = f"{ISSUER}/protocol/openid-connect/certs"
jwks_client = PyJWKClient(JWKS_URL, cache_keys=True)
def verify_token(token: str) -> dict:
signing_key = jwks_client.get_signing_key_from_jwt(token)
return jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
issuer=ISSUER,
audience="my-api",
leeway=5,
options={"require": ["exp", "iat", "iss"]},
)
Call verify_token() from a FastAPI dependency, a Django REST Framework authentication class, or a Flask decorator, and raise your framework’s 401 when jwt.decode throws jwt.InvalidTokenError. The leeway=5 gives you five seconds of clock-skew tolerance, and options={"require": [...]} makes missing claims a hard failure instead of a silent pass.
When should you use token introspection instead?
Local validation has one blind spot: revocation. Once a token is signed and in the wild, your backend will accept it until exp, even if the user logged out, got disabled, or had their session killed by an admin ten seconds ago. Token introspection (RFC 7662) closes that gap by asking Keycloak directly: is this token still active right now?
Reach for introspection when a request is sensitive enough that a few minutes of staleness is unacceptable: payment initiation, admin actions, account deletion, or when compliance requires immediate revocation. For everything else, short token lifetimes (Keycloak defaults to 5 minutes) keep the revocation window small enough that local validation is the right trade. The full decision framework, including hybrid patterns and gateway setups, lives in our companion deep dive on token introspection vs local validation; this section covers the mechanics.
Calling the introspection endpoint
The endpoint requires authentication, so you need a confidential client in your realm (a client with “Client authentication” enabled and a secret). Public clients can’t introspect. Then it’s a form-encoded POST with HTTP Basic auth:
curl -X POST
"https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token/introspect"
-u "my-backend:my-client-secret"
-d "token=eyJhbGciOiJSUzI1NiIs..."
A live token returns its claims plus "active": true:
{
"active": true,
"exp": 1751457900,
"aud": "my-api",
"sub": "b6f4b1c2-8a3e-4f7d-9c21-5e8f0a1d3b47",
"azp": "frontend",
"scope": "openid email profile"
}
A revoked, expired, or unknown token returns just {"active": false}. Your code needs exactly one check: if active is not true, reject with 401. Don’t try to interpret why it’s inactive; the spec deliberately doesn’t tell you.
Two operational notes. First, every introspection call adds a round trip and load on Keycloak, so if you introspect on a hot path, cache positive results for a short TTL (10 to 30 seconds). Second, treat the client secret like any credential: secrets manager or environment variable, never source control.
Local validation vs token introspection at a glance
This comparison is the heart of the decision. Most backends land on local validation for the request path and introspection for the handful of operations that genuinely need real-time answers.
| Local validation | Token introspection | |
|---|---|---|
| Network call per request | No (JWKS cached) | Yes, one round trip to Keycloak |
| Detects revocation instantly | No, token valid until exp |
Yes |
| Added latency | Microseconds | Milliseconds (network + Keycloak processing) |
| Load on Keycloak | Near zero after JWKS fetch | Scales linearly with your traffic |
| Credentials needed | None (public keys) | Confidential client ID + secret |
| Works offline / Keycloak down | Yes | No |
| Works with opaque (non-JWT) tokens | No | Yes |
| Setup complexity | JWT library + issuer URL | Client config + secret management + caching |
What are the most common token verification mistakes?
We debug a lot of Keycloak deployments at Skycloak, and token verification bugs cluster around the same few patterns:
Skipping audience validation. Without an aud or azp check, any valid token from your realm, issued to any client for any API, gets accepted by yours. That turns one compromised low-value client into a skeleton key. And because Keycloak doesn’t put your API in aud by default, many teams “fix” a failing audience check by deleting it. Add the mapper instead: create an “Audience” protocol mapper in a client scope, point it at your API’s client ID, and attach the scope to the requesting client.
Trusting the algorithm from the token header. If your verification code reads alg from the incoming token and verifies with whatever it says, an attacker controls your verification method. Always pass an explicit allowlist (algorithms: ['RS256']) as shown in every example above.
Calling Keycloak on every request. Introspecting every API call, or re-fetching JWKS every call, adds a round trip to every request and makes Keycloak a single point of failure for your API tier. Validate locally by default; introspect selectively.
Hardcoding the realm public key. It works in the demo, then key rotation ships a new kid and every request 401s. Point at the JWKS URL and let the library manage the cache.
Decoding without verifying. jwt.decode(token, options={"verify_signature": False}) and its equivalents exist for debugging, and every year they end up in production auth paths, where they accept any token anyone typed by hand. A decode call with no key material is an incident waiting for a timestamp.
For broader guidance beyond Keycloak specifics, our JWT best practices for developers covers storage, lifetimes, and claim design in depth.
Security checklist for production
Before you ship, run through this list:
- Signature verified against the JWKS endpoint, with automatic refetch on unknown
kid - Algorithm allowlist pinned in code (RS256 unless your realm says otherwise)
isschecked against the exact realm URL your clients seeexpandnbfenforced with 5 to 30 seconds of leeway, no moreaud(via audience mapper) orazpchecked against your API’s identity- Access token lifespan short (the 5-minute Keycloak default is sensible), with refresh token rotation handling session longevity
- Introspection used at high-sensitivity boundaries, with the client secret in a secrets manager
- Raw tokens kept out of logs, error messages, and URLs
- 401 for authentication failures, 403 for authorization failures, and no claim details leaked in either
Frequently asked questions
How do I get the public key from Keycloak?
Fetch it from the realm’s JWKS endpoint: https://<host>/realms/<realm>/protocol/openid-connect/certs. Don’t copy the key out of the admin console into your config; keys rotate, and a hardcoded key breaks silently when they do. Configure your JWT library with the URL and let it cache and refresh keys automatically.
Should I use token introspection or local validation?
Local validation for the default request path: it’s faster, needs no credentials, and keeps working if Keycloak is briefly unreachable. Introspection for operations where instant revocation matters. Many production systems combine both. The trade-offs, caching strategies, and hybrid patterns are covered in detail in our introspection vs local validation deep dive.
Why is my Keycloak token signature invalid?
The usual suspects: your backend is validating against the wrong realm’s keys; the issuer URL differs between how the token was minted and how your backend reaches Keycloak (internal Docker hostname vs public URL is the classic); the key was rotated and your key cache is stale; or the token got truncated or re-encoded in transit. Decode the token in our JWT Token Analyzer and compare iss and kid against what your backend expects.
Do I need a client secret to verify tokens?
Not for local validation: signature checks use the realm’s public keys, which are, as the name suggests, public. You only need a confidential client and its secret for the introspection endpoint, which refuses unauthenticated calls.
Why is my API’s client ID not in the aud claim?
Because Keycloak doesn’t add it by default; fresh tokens typically carry "aud": "account". Add an Audience protocol mapper to a client scope, attach the scope to the requesting client, and then require your API’s identifier during validation.
Wrapping up
Verifying a Keycloak access token on the backend comes down to a repeatable recipe: fetch the realm’s JWKS, verify the RS256 signature against the key matching the token’s kid, then validate iss, exp, and aud with a pinned algorithm and a little clock leeway. Spring’s resource server starter, jose, and PyJWT each get you there in under twenty lines. Layer introspection on top only where real-time revocation earns its round trip.
The verification code is the easy part; keeping the Keycloak behind it patched, monitored, and highly available is the ongoing cost. If you’d rather ship features than babysit an identity server, Skycloak’s managed Keycloak hosting gives you production-grade Keycloak with backups, upgrades, and monitoring handled for you.