Skip to content

Spring Boot · Security

JWT and OAuth2 in Spring Boot, Explained

6 min read Updated 2026-06-26 Share:

Practice JWT & OAuth2 interview questions

Tokens that carry their own proof

Server sessions keep state on the server; JWTs flip that around. A JWT is a compact, self-contained token whose signature lets any server verify it — no session store, no database lookup. That single property is why JWTs dominate REST APIs and microservices, and why they come with their own set of sharp edges. Let's go through how Spring Boot uses them and where teams get burned.

What's inside a JWT

Three base64url parts, dot-separated: header, payload (claims), and signature:

eyJhbGciOiJSUzI1NiJ9 . eyJzdWIiOiJhbGljZSIsInJvbGUiOiJBRE1JTiJ9 . <signature>
  header (alg)          payload (sub, role, exp, iss, aud, ...)    signature

The crucial thing to say in an interview: the payload is encoded, not encrypted. Anyone can base64url- decode it and read the claims. So a JWT proves integrity (it wasn't tampered with) but provides no confidentiality — never put secrets or sensitive PII in it.

Why they're stateless

Because the token carries its own proof, the server just validates the signature and expiry on each request and trusts the claims inside. Any node holding the verification key can authenticate independently:

each request: verify signature with issuer's public key → read claims → authorize

That's what enables horizontal scaling and clean service-to-service auth — services share the issuer's public key and validate locally. The trade-off is revocation: a self-contained token stays valid until it expires, which is why access tokens are kept short-lived.

Spring as a resource server

To protect an API with JWTs, make the app an OAuth2 resource server. Add the starter, point it at the issuer, and enable jwt():

spring.security.oauth2.resourceserver.jwt.issuer-uri: https://issuer.example.com/
http.authorizeHttpRequests(a -> a.anyRequest().authenticated())
    .oauth2ResourceServer(o -> o.jwt(withDefaults()))
    .sessionManagement(s -> s.sessionCreationPolicy(STATELESS));

You write no token-parsing code. Spring fetches the issuer's public keys (JWKS), then validates the signature, expiry, and issuer on every Authorization: Bearer <jwt>, exposing the claims as a JwtAuthenticationToken. This is the standard, boring-in-a-good-way way to secure a REST API.

What "validate" really means

Validation is the whole basis of trust, so know what's checked: the signature (against the cached JWKS), the expiry (exp) and timing (nbf/iat), and the issuer (iss). You should also check the audience (aud) so a token minted for another service can't be replayed against yours:

decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
    JwtValidators.createDefaultWithIssuer(issuer),
    new JwtClaimValidator<List<String>>("aud", aud -> aud.contains("my-api"))));

The cardinal sin is not verifying the signature — treating claims as trusted because they look right — or accepting alg: none. Pin the expected algorithm and never accept an unsigned token.

OAuth2 vs OpenID Connect

These get conflated constantly. OAuth2 is an authorization framework — it issues access tokens that let a client call APIs on a user's behalf. OpenID Connect (OIDC) adds a thin authentication layer on top, with an ID token describing who the user is:

OAuth2 → access token → "this client may call the API"   (authorization)
OIDC   → id token      → "this is the authenticated user" (authentication)

"Log in with Google" is OIDC. Calling an API with a delegated scope is OAuth2. Spring's oauth2Login speaks OIDC for login; the resource server consumes OAuth2 access tokens.

Social login with oauth2Login

To delegate login to an external IdP, add the client starter and enable oauth2Login():

spring.security.oauth2.client.registration.google:
  client-id: ${GOOGLE_ID}
  client-secret: ${GOOGLE_SECRET}
  scope: openid,profile,email
http.oauth2Login(withDefaults());

This makes the app an OAuth2 client — different from a resource server, though an app can be both. Spring runs the full authorization-code flow: redirect the user to the provider, get a one-time code back, and exchange it server-to-server for tokens. The tokens never pass through the browser URL; public clients (SPAs, mobile) add PKCE to secure the exchange without a secret. Use this flow — the old implicit flow is deprecated.

Scopes become authorities

By default the resource server maps each scope entry to an authority prefixed SCOPE_. You authorize on those, or convert your own claims:

auth.requestMatchers("/api/admin/**").hasAuthority("SCOPE_admin");

// map a custom "roles" claim to ROLE_ authorities:
var roles = new JwtGrantedAuthoritiesConverter();
roles.setAuthoritiesClaimName("roles");
roles.setAuthorityPrefix("ROLE_");

So a token with scope: "admin" satisfies hasAuthority("SCOPE_admin"). Supply a JwtAuthenticationConverter when your IdP encodes permissions under a different claim.

Access tokens and refresh tokens

These solve different problems. An access token is short-lived (minutes) and sent on every API call. A refresh token is long-lived, kept secret, and used only against the authorization server to mint new access tokens:

access token  → API calls, short TTL, exposed often
refresh token → token endpoint only, long TTL, secret, revocable

Short access lifetimes limit the blast radius of a leak; the refresh token lives somewhere safer (an HttpOnly cookie, secure storage) and — crucially — can be revoked. That pairing buys back the revocation that bare JWTs lack.

Revoking the unrevocable

You can't "delete" a signed JWT, so you reintroduce state selectively: keep access TTLs short, revoke the refresh token server-side, and for instant access-token cut-off, check a deny-list by token id (jti) on each request:

if (revokedTokenStore.contains(jwt.getId()))
    throw new OAuth2AuthenticationException("revoked");

The deny-list only needs entries until each token's natural expiry, so it stays small. Most apps lean on short TTLs plus refresh-token revocation and skip the deny-list unless they need immediate logout.

Keys and storage: two decisions people get wrong

Signing keys: symmetric HMAC (HS256) uses one shared secret to sign and verify — fine when a single service does both. Asymmetric (RS256) signs with a private key and verifies with a public one, so many resource servers can validate without ever holding the secret. In OAuth2 setups the IdP signs with RS256 and publishes its public keys at a JWKS endpoint — the norm for distributed systems.

Client storage: avoid localStorage — any XSS reads it. Prefer an HttpOnly, Secure cookie (not JS-readable, but reintroduces CSRF concerns), or hold a short-lived access token in memory with the refresh token in an HttpOnly cookie. There's no free option; pick based on your XSS-vs-CSRF posture and keep lifetimes short.

JWT or sessions?

JWTs aren't automatically better — they move state to the client and complicate revocation. Choose them for stateless, scaled-out, multi-service, or mobile/SPA scenarios where any node must authenticate any request. Choose sessions for classic server-rendered apps that value simple, immediate logout. Match the tool to the topology rather than reaching for JWTs by reflex.

Recap

A JWT is signed, self-describing claims that prove integrity without server state — but they're readable, so no secrets, and they're hard to revoke, so keep them short-lived. Protect APIs with the resource-server starter and an issuer-uri; always verify signature, expiry, issuer, and audience, and pin the algorithm. Know that OAuth2 is authorization and OIDC is authentication; use oauth2Login and the authorization-code flow (with PKCE for public clients) for delegated login. Map scopes to authorities, pair short access tokens with revocable refresh tokens, prefer asymmetric RS256 + JWKS for multi-service setups, keep tokens out of localStorage, and reach for JWTs only when the topology actually calls for stateless auth.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel