JWT resource servers

Validating a JWT: signature, issuer, audience, expiry, and the claims-to-authorities mapping. Also, what not to put in a token.

5 min read🔐 Spring Security

A JWT proves that somebody holding a key signed these claims, and they have not been altered since. That is all it proves, and most JWT mistakes come from believing it proves something else.

Read one without a key

Here is a token as it travels:

plaintext
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTE
xMzgiLCJlbWFpbCI6ImFuYUBleGFtcGxlLmNvbSIsInJvbGVzIjpbIkFETUlOIl0sImlzcyI6Imh0d
HBzOi8vYXV0aC5leGFtcGxlLmNvbSIsImF1ZCI6Im9yZGVycy1hcGkiLCJpYXQiOjE3ODgwMDAwMDA
sImV4cCI6MTc4ODAwMzYwMH0.RJG8rAey0j-In78v5x7zV2eOt2CqHRJIdeikQkMi5uM

Three parts separated by dots. Split on the dots, base64-decode the first two, and no key is involved:

plaintext
{"alg":"HS256","typ":"JWT"}
{"sub":"user-1138","email":"ana@example.com","roles":["ADMIN"],
 "iss":"https://auth.example.com","aud":"orders-api",
 "iat":1788000000,"exp":1788003600}

That is the single most important fact about JWTs and the one most often forgotten: a JWT is signed, not encrypted. Every claim is public to anyone who sees the token — the user's browser, an intermediary, a log file, a bug report with a header pasted into it.

So the rule for what goes in a token is short. Nothing secret. Nothing you would mind publishing. Prefer an opaque subject id over an email address. And keep it small, because it travels on every request — a token with forty claims is forty claims of overhead per call.

What validation actually has to check

The signature is the first check and not the only one. A resource server has to answer is this token for me, from someone I trust, and still current — and each of those is a separate claim:

CheckClaimWhat goes wrong without it
signatureanyone can write any token
issuerissa validly signed token from a different system is accepted
audienceauda token meant for another service of yours is accepted
expiryexptokens never stop working
not beforenbfa token is usable before it should be

The two in bold are the ones people skip, and both have caused real breaches. A signature says somebody with a key signed this. It does not say the key was yours or this token was meant for this service. If two of your services trust the same issuer, a token for the low-privilege one is a perfectly valid signature at the high-privilege one — unless aud is checked.

Configuring a resource server

Spring does the validation for you, and the configuration is deliberately small:

properties
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://auth.example.com

That one line makes Spring fetch the issuer's OpenID configuration, find the JWK Set endpoint, download the public keys, and validate signature, issuer and expiry on every request. Audience is not checked by default — you add it:

java
@Bean JwtDecoder jwtDecoder(OAuth2ResourceServerProperties props) {
    NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(props.getJwt().getIssuerUri());
    decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
        JwtValidators.createDefaultWithIssuer(props.getJwt().getIssuerUri()),
        new JwtClaimValidator<List<String>>("aud", a -> a != null && a.contains("orders-api"))));
    return decoder;
}

With asymmetric keys — RS256, which is what an identity provider will use — your service only ever holds the public key. It can verify tokens and cannot mint them, which is exactly the property you want in a service that might be compromised.

Key rotation then costs nothing: the provider publishes a new key at the JWK Set URL with a new kid, tokens carry the kid they were signed with, and Spring fetches and caches what it needs. Nothing is deployed on your side.

Claims are not authorities until you map them

By default Spring turns a scope or scp claim into authorities prefixed SCOPE_. Your token probably has roles somewhere else, so you map them:

java
@Bean JwtAuthenticationConverter converter() {
    JwtGrantedAuthoritiesConverter authorities = new JwtGrantedAuthoritiesConverter();
    authorities.setAuthorityPrefix("ROLE_");
    authorities.setAuthoritiesClaimName("roles");
    JwtAuthenticationConverter c = new JwtAuthenticationConverter();
    c.setJwtGrantedAuthoritiesConverter(authorities);
    return c;
}

Get the prefix wrong and hasRole("ADMIN") silently never matches — the same ROLE_ trap as the filter chain lesson, arriving from a different direction.

The costs you are accepting

Everything the sessions lesson said about statelessness applies here, concretely:

  • No revocation. A token is valid until exp whatever happens to the user's account. Keep access tokens short — 5 to 15 minutes — and put revocation on the refresh token, which lives server-side.
  • Stale claims. roles: ["ADMIN"] keeps asserting that after the role is removed, for the life of the token. Anything that must be current is read at request time, not taken from the token.
  • A blocklist gives revocation back and takes statelessness away. It is a store lookup on every request. Sometimes right; it should be a decision.
Progress is saved on this device and to your account when signed in.