OAuth2 and JWT for APIs
Who issues tokens, who checks them, what a JWT actually contains, and why a Spring Boot API is usually the resource server rather than the issuer.
GET /api/orders HTTP/1.1
Authorization: Bearer <header>.<payload>.<signature>
header: {"alg":"RS256","kid":"2026-10"}
payload: {"iss":"...","sub":"...","aud":"...","exp":...,"scope":"orders:read"}Session cookies work well when one server renders pages for one browser. An API has other callers: a single-page app, a mobile app, a partner's batch job, another of your own services. They need a credential they can send on every request without the API keeping a session for each of them. OAuth2 defines who hands out that credential, and a JWT is the most common format for it.
The four roles
- Resource owner: the user whose data it is.
- Client: the app acting for the user, or acting for itself.
- Authorization server: logs the user in and issues access tokens. Keycloak, Auth0, Okta, Microsoft Entra ID and Spring Authorization Server all play this part.
- Resource server: your API. It receives
Authorization: Bearer <token>, checks the token and decides what the caller may do.
Keeping these apart is the point. Your API never sees a password, so it never stores one, and login features such as multi-factor authentication or social sign-in live in one place for every app you run.
What a JWT is
A JSON Web Token is three base64url parts joined by dots: a header naming the signing algorithm and key id (kid), a payload of claims, and a signature. The standard claims are iss (issuer), sub (subject, usually a user id), aud (intended audience), exp, iat and nbf (times), and jti (a unique id). Authorization servers add scope and often a roles claim.
The token is signed, not encrypted. Anyone holding it can read the payload, so never put personal data or secrets in claims. The signature only proves who issued it and that nobody changed it.
Why APIs like JWTs, and what they cost
The API checks a JWT with the issuer's public key and the clock. There is no database lookup and no call to the issuer per request, which suits horizontally scaled services. The price is revocation: a signed token stays valid until exp, even after the user logs out or is disabled. That is why access tokens are short-lived and paired with refresh tokens.
Which flow
- Authorization code with PKCE for browser and mobile apps where a user logs in.
- Client credentials for service-to-service calls with no user.
- The password grant and the implicit grant are deprecated by the OAuth 2.0 Security Best Current Practice (RFC 9700). Do not build new clients on them.
In Spring Boot the common case is a resource server with spring-boot-starter-oauth2-resource-server trusting an external issuer. Issuing your own tokens with JwtEncoder is reasonable for a single API with its own users, but you then own key management, rotation and refresh, which is real work to get right.
A frequent mistake in browser apps is keeping tokens in localStorage, where any injected script can read them. A backend-for-frontend that keeps tokens on the server and gives the browser an HttpOnly session cookie avoids that.
Example
import java.time.Instant;
import java.util.List;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
record TokenInfo(String subject, String issuer, List<String> audience, Instant expiresAt, String scope) {
}
("/api/me")
public class TokenInfoController {
// Spring Security has already verified the signature, issuer and expiry
// before this method runs. The Jwt is the parsed, trusted token.
("/token")
TokenInfo token( Jwt jwt) {
return new TokenInfo(
jwt.getSubject(),
jwt.getIssuer() == null ? null : jwt.getIssuer().toString(),
jwt.getAudience(),
jwt.getExpiresAt(),
jwt.getClaimAsString("scope"));
}
}
When to use it
- A React single-page app and an iOS app log users in through Keycloak and call the same Spring Boot orders API with bearer tokens.
- A nightly reporting job uses client credentials to read orders without any user account.
- Several internal services accept tokens from one company identity provider instead of each keeping its own user table.
More examples
A client credentials token request
A service with no user behind it authenticates as itself with its client id and secret (here the obviously fake <code>reporting-job:change-me</code>) and receives a short-lived access token. Your Spring Boot API never sees that secret; it only checks the token the job sends afterwards. The <code>/oauth2/token</code> path is common but each authorization server publishes its own in its metadata.
POST /oauth2/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic cmVwb3J0aW5nLWpvYjpjaGFuZ2UtbWU=
grant_type=client_credentials&scope=orders:read
HTTP/1.1 200 OK
Content-Type: application/json
{"access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYtMTAifQ...","token_type":"Bearer","expires_in":300,"scope":"orders:read"}
A decoded access token payload
Times are seconds since the epoch, and this token lives five minutes. <code>aud</code> says which API it was minted for: a resource server that skips the audience check will accept a token meant for a different API from the same issuer. <code>scope</code> is a space-separated string, which Spring Security turns into <code>SCOPE_orders:read</code> and <code>SCOPE_orders:write</code> authorities.
{
"iss": "https://auth.example.com/realms/shop",
"sub": "8c1f3e0a-2b7d-4c55-9a51-6f0f2d3b9e11",
"aud": "orders-api",
"iat": 1791878400,
"nbf": 1791878400,
"exp": 1791878700,
"jti": "5b8f0e7c-0d2a-4e61-8d0e-1b2a3c4d5e6f",
"scope": "orders:read orders:write",
"roles": ["customer"]
}
Verifying a token outside the filter chain
Never base64-decode a token yourself and trust what you read: that skips the signature check entirely. Reuse the same <code>JwtDecoder</code> bean the resource server uses, so the same keys and validators apply. <code>decode</code> throws a <code>JwtException</code> subclass for a bad signature, an expired token or a failed validator.
import java.util.Optional;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.stereotype.Component;
@Component
public class TicketTokenVerifier {
private final JwtDecoder jwtDecoder;
TicketTokenVerifier(JwtDecoder jwtDecoder) {
this.jwtDecoder = jwtDecoder;
}
// For places where no Authorization header arrives, such as a token passed
// in a WebSocket handshake or a signed download link.
public Optional<String> subjectOf(String token) {
try {
Jwt jwt = jwtDecoder.decode(token); // signature, expiry and issuer checks
return Optional.ofNullable(jwt.getSubject());
} catch (JwtException ex) {
return Optional.empty();
}
}
}
Discussion