Skip to content
Commitwise
Menu
Java Backend

Spring Security, JWT and OAuth2

Protect a Spring Boot API with a security filter chain, validate JWT access tokens from an OAuth2 authorization server, check permissions per endpoint and per object, and get mobile login right with authorization code and PKCE, in Java and Kotlin.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The order API works. Any logged-in user can see their orders at /api/orders/1042. They can also see everyone else’s at /api/orders/1043, because the endpoint checks that you’re logged in, not who you are. Security bugs rarely look like bugs in a demo; they look like a feature working too well. Spring Security handles the hard, standard parts; this lesson is about wiring them up correctly and adding the checks only your code can make.

New to some of these? Open what you need, or skip ahead.

Basics:How do HTTP requests work?

Apps and servers talk with HTTP. A request has a method that says what to do (GET to read, POST to create, PUT to replace, DELETE to remove), a URL (https://api.example.com/books/42), headers (extra information, like who you are) and sometimes a body (the data, usually JSON).

The server sends back a response with a status code that says how it went (200 OK, 201 created, 404 not found, 500 server error), headers, and usually a body. Codes starting with 4 mean the client asked for something wrong; codes starting with 5 mean the server failed.

Basics:What is Spring Boot?

Spring Boot is the most popular framework for Java and Kotlin backends. You write classes and mark them with annotations; Spring creates the objects (called beans), connects them to each other (dependency injection: a controller gets the service it needs through its constructor) and runs a web server that sends HTTP requests to your methods.

Starters (spring-boot-starter-web, spring-boot-starter-data-jpa…) add features by adding a dependency, with sensible defaults you can change in application.yml.

Basics:Authentication or authorization?

Authentication answers “who are you?”: logging in with a password, a fingerprint or a token proves identity. Authorization answers “what may you do?”: an authenticated user may read their own orders but not delete products.

HTTP has a status code for each failure: 401 Unauthorized means “I don’t know who you are” (no or invalid credentials), 403 Forbidden means “I know who you are, and you’re not allowed”.

Part Not started

The security filter chain

An office building has a security desk at the entrance and badge readers on some doors. The desk checks who you are; each reader checks whether your badge opens that door. The lobby is open to everyone, the offices need any employee badge, and the server room needs a special one.

Spring Security is that building’s security. Before a request reaches your controller, it passes a chain of filters that figures out who sent it (from a token or a session) and whether they may use that URL. You describe the rules in one place: a SecurityFilterChain.

Spring Security installs a DelegatingFilterProxy that routes each request through the first matching SecurityFilterChain: authentication filters populate the SecurityContext, the AuthorizationFilter evaluates authorizeHttpRequests rules (first match wins), and exception translation turns failures into 401 (via the AuthenticationEntryPoint) or 403 (via the AccessDeniedHandler). Method security (@PreAuthorize) adds checks at the service layer. Defaults are secure (everything authenticated, CSRF on, sessions as needed); a stateless token API changes the last two deliberately.

@Configuration
@EnableMethodSecurity
class SecurityConfig {

    @Bean
    fun api(http: HttpSecurity): SecurityFilterChain {
        http {
            csrf { disable() }                                   // bearer tokens, no browser session
            sessionManagement { sessionCreationPolicy = SessionCreationPolicy.STATELESS }
            authorizeHttpRequests {
                authorize("/actuator/health/**", permitAll)
                authorize("/api/products/**", permitAll)
                authorize("/api/admin/**", hasAuthority("SCOPE_admin"))
                authorize(anyRequest, authenticated)             // everything else needs a token
            }
            oauth2ResourceServer { jwt { } }
        }
        return http.build()
    }
}
@Configuration
@EnableMethodSecurity
class SecurityConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())                       // bearer tokens, no browser session
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health/**", "/api/products/**").permitAll()
                .requestMatchers("/api/admin/**").hasAuthority("SCOPE_admin")
                .anyRequest().authenticated())                  // everything else needs a token
            .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()));
        return http.build();
    }
}

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

Reading it top to bottom:

  • Public: the health check (for the load balancer) and browsing products.
  • Admin only: anything under /api/admin/ needs the admin scope in the token.
  • Everything else: any valid token.
  • oauth2ResourceServer { jwt }: tokens come in the Authorization: Bearer … header and are checked against the login server configured in issuer-uri (signature, expiry, issuer).
  • CSRF off, no sessions: this API is called by apps sending tokens, not by browsers with cookies, so it doesn’t need either.
Spot the bugEvery user is an admin

Tap the line with the bug.

@Bean
fun api(http: HttpSecurity): SecurityFilterChain {
    http {
        authorizeHttpRequests {
            authorize("/api/**", authenticated)
            authorize("/api/admin/**", hasAuthority("SCOPE_admin"))
            authorize(anyRequest, permitAll)
        }
        oauth2ResourceServer { jwt { } }
    }
    return http.build()
}
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/**").authenticated()
            .requestMatchers("/api/admin/**").hasAuthority("SCOPE_admin")
            .anyRequest().permitAll())
        .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
    return http.build();
}

Predict the output

With the corrected config: (1) a request to /api/orders without a token, (2) a request to /api/admin/products with a normal user's valid token. Which status codes come back?

Exerciseeasy

Rules for a shop API

Write the authorizeHttpRequests rules for: anyone can GET /api/products/**; only users with the catalog:write scope can POST, PUT or DELETE products; /api/cart/** and /api/orders/** need any logged-in user; /api/admin/** needs the admin scope; everything else is denied.

›

requestMatchers can take an HTTP method first: requestMatchers(HttpMethod.GET, "/api/products/**"). “Everything else is denied” is anyRequest().denyAll().

SolutionTry it yourself first
authorizeHttpRequests {
    authorize("/api/admin/**", hasAuthority("SCOPE_admin"))
    authorize(HttpMethod.GET, "/api/products/**", permitAll)
    authorize("/api/products/**", hasAuthority("SCOPE_catalog:write"))
    authorize("/api/cart/**", authenticated)
    authorize("/api/orders/**", authenticated)
    authorize(anyRequest, denyAll)
}
http.authorizeHttpRequests(auth -> auth
        .requestMatchers("/api/admin/**").hasAuthority("SCOPE_admin")
        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
        .requestMatchers("/api/products/**").hasAuthority("SCOPE_catalog:write")
        .requestMatchers("/api/cart/**", "/api/orders/**").authenticated()
        .anyRequest().denyAll());

The GET rule comes before the general products rule, so reading stays public while every other method on products needs the write scope. denyAll at the end means a new endpoint is closed until someone adds a rule for it, instead of open by accident.

CSRF, CORS and sessions

  • CSRF protects cookie-based browser sessions from cross-site forms. A stateless API that only accepts Authorization: Bearer headers isn’t vulnerable and can disable it; an API that also accepts session cookies (a web app’s BFF) must keep it on.
  • CORS is a browser rule, not authentication: configure allowed origins for browser clients (http.cors { } with a CorsConfigurationSource); mobile apps and servers ignore it.
  • Several chains: use securityMatcher("/api/**") on one SecurityFilterChain and another chain for the admin web UI with form login and sessions; order them with @Order.
  • SecurityFilterChain beans; authorizeHttpRequests rules are first-match-wins: specific → general, end with authenticated() or denyAll().
  • Stateless token APIs: STATELESS sessions, CSRF off only if no cookie auth; CORS for browser clients only.
  • 401 from the entry point (no/invalid credentials), 403 from the access-denied handler (insufficient authority).
  • Multiple chains via securityMatcher + @Order for APIs vs web UIs. Test each rule with anonymous/user/admin.

Check this part

  1. In authorizeHttpRequests, which rule applies when several match?

  2. A request with a valid token lacks the admin scope. What status?

  3. When is it safe to disable CSRF protection?

Spring Security runs a chain of filters before your controllers. A SecurityFilterChain bean decides which URLs are public, which need a logged-in user and which need a role or scope. Rules are checked in order and the first match wins, so put the most specific paths first.

Part Not started

JWT access tokens

A festival wristband shows your ticket type and the date, and it’s tamper-proof: security at each stage just looks at it, without calling the ticket office. The catch: if you’re thrown out, your wristband still looks valid until it expires.

A JWT (JSON Web Token) is that wristband. The login server signs a small JSON document (who you are, what you may do, when it expires), and your API checks the signature on every request, with no database lookup. It’s fast and simple, and it’s why tokens must expire quickly.

A JWT access token is a JWS: base64url header and payload with a signature (typically RS256/ES256 with the authorization server’s key, published as a JWKS). Resource servers validate signature, iss, exp/nbf, and usually aud, then map claims to authorities (scope/scp → SCOPE_x by default). Validation is local, so revocation before expiry needs short lifetimes, refresh-token rotation, or introspection (opaque tokens) for sensitive operations. Never accept alg: none, never trust unverified claims, and don’t put secrets or PII in a token: it’s signed, not encrypted.

eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYtMTEifQ   ← header:  {"alg":"RS256","kid":"2026-11"}
.eyJzdWIiOiJ1c2VyLTQyIiwic2NvcGUiOiJvcmRlcnM6cmVhZCIsImV4cCI6MTc5NDE2MDAwMH0
                                              ← payload: {"sub":"user-42","scope":"orders:read","exp":1794160000}
.QmC1…signature                               ← signed with the login server's private key
@RestController
@RequestMapping("/api/orders")
class OrderController(private val orders: OrderService) {

    @GetMapping
    fun myOrders(@AuthenticationPrincipal jwt: Jwt): List<OrderDto> =
        orders.forCustomer(jwt.subject)                         // "sub": who the token belongs to

    @PostMapping
    @PreAuthorize("hasAuthority('SCOPE_orders:write')")         // method-level check
    fun place(@AuthenticationPrincipal jwt: Jwt, @RequestBody @Valid request: PlaceOrder): OrderDto =
        orders.place(jwt.subject, request)
}
@RestController
@RequestMapping("/api/orders")
class OrderController {
    private final OrderService orders;
    OrderController(OrderService orders) { this.orders = orders; }

    @GetMapping
    List<OrderDto> myOrders(@AuthenticationPrincipal Jwt jwt) {
        String userId = jwt.getSubject();                       // "sub": who the token belongs to
        return orders.forCustomer(userId);
    }

    @PostMapping
    @PreAuthorize("hasAuthority('SCOPE_orders:write')")         // method-level check
    OrderDto place(@AuthenticationPrincipal Jwt jwt, @RequestBody @Valid PlaceOrder request) {
        return orders.place(jwt.getSubject(), request);
    }
}

The controller never reads the Authorization header or checks a signature itself; Spring Security already did, and hands over the validated token as a Jwt. jwt.getSubject() is the user’s id. Use it to decide whose data to return; never take the user id from the request body or the URL.

Token lifetimes

Predict the output

Access tokens live 1 hour. A user taps Log out at 10:05; the app deletes its tokens and calls the server's logout endpoint, which revokes the refresh token. An attacker had copied the access token at 10:00. Can they call the API at 10:30?

Validating more than the signature

  • Audience: a token issued for another API on the same authorization server validates fine unless you check aud. Add a JwtValidators audience validator via a custom JwtDecoder.
  • Claims to authorities: map roles from your IdP’s claim (Keycloak realm_access.roles, Auth0 custom namespaces) with a JwtAuthenticationConverter, so hasRole works.
  • Clock skew and key rotation: Spring’s decoder allows 60 s of skew and refetches the JWKS when it sees an unknown kid; don’t cache keys forever in custom code.
  • Don’t build your own: issuing tokens, password storage and login belong to an authorization server (Keycloak, Auth0, Cognito, Spring Authorization Server). Your API is a resource server.
  • JWT = signed claims (JWS); resource servers validate signature (JWKS), iss, exp, plus aud (add explicitly). Not encrypted: no secrets/PII inside.
  • Local validation means no early revocation: short access tokens (5–15 min), rotating refresh tokens with reuse detection, introspection or freshness checks for high-risk actions.
  • Map IdP claims to authorities with a JwtAuthenticationConverter; @PreAuthorize for method security. Identity always from the token (sub), never from request data.

Check this part

  1. Where should an endpoint get the current user's id from?

  2. Is the content of a JWT secret?

  3. A user logs out. Can their stolen access token still be used?

A JWT is a signed token with claims (who the user is, scopes, expiry). The API validates the signature and claims on every request without a database lookup, which also means it can't revoke a token early. Keep access tokens short-lived, use refresh tokens, and never build your own token handling.

Part Not started

OAuth2 for apps, and checking every object

How a mobile app logs in

  1. The app opens the login page of the authorization server in the system browser (ASWebAuthenticationSession on iOS, Custom Tabs on Android), with a random PKCE challenge.
  2. The user logs in there, not in your app’s own form, so the app never sees the password, and the login server can offer passkeys, 2FA and social login.
  3. The server redirects back to the app with a one-time authorization code.
  4. The app exchanges the code (plus the PKCE secret that only it knows) for an access token and a refresh token.
  5. The app calls your API with Authorization: Bearer <access token>, and uses the refresh token to get a new access token when it expires. Tokens are stored in the Keychain (iOS) or Keystore-backed storage (Android).

This is the authorization code flow with PKCE, the standard for mobile and single-page apps. Libraries like AppAuth handle it. Server-to-server calls (a nightly job calling another service) use the simpler client credentials flow instead, with no user involved.

Public clients (mobile, SPA) can’t keep a client secret, so OAuth 2.1 mandates authorization code + PKCE and drops the implicit and password grants. Use the system browser (not an embedded WebView, which can capture credentials and breaks SSO), claimed HTTPS redirect URIs (Universal Links / App Links) over custom schemes where possible, and consider a backend-for-frontend for web apps so tokens never live in JavaScript. Machine-to-machine uses client credentials with narrowly scoped clients.

Authorization on every object

Spot the bugReading other customers' orders

Tap the line with the bug.

@GetMapping("/api/orders/{id}")
fun order(@PathVariable id: Long): OrderDto =
    orders.findById(id)
        .map(OrderDto::from)
        .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND) }
@GetMapping("/api/orders/{id}")
OrderDto order(@PathVariable long id) {
    return orders.findById(id)
            .map(OrderDto::from)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}

Exercisemedium

Token lifecycle for a mobile app

Design login and token handling for a banking-style mobile app: login with passkeys or password + 2FA, sessions that last weeks without re-login, immediate effect when the user logs out or a device is reported stolen, and protection against a stolen refresh token. Name the flows, lifetimes, storage and server-side checks.

›

Separate what the app holds (and for how long) from what the server can revoke. Think about what happens when a refresh token is used twice.

SolutionTry it yourself first
  • Login: authorization code + PKCE through the system browser, against an authorization server that supports passkeys and step-up 2FA; claimed HTTPS redirect URIs.
  • Access tokens: JWT, 5–10 minutes, audience-restricted to the banking API, scopes per feature (accounts:read, payments:write).
  • Refresh tokens: opaque, stored server-side, rotated on every use; reusing an already-used refresh token revokes the entire token family (it means two parties hold it). Sender-constrained if possible (DPoP binds tokens to a device key, so a copied token is useless elsewhere).
  • Storage: Keychain with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly on iOS; Android Keystore-backed encryption; never in plain preferences or logs.
  • Logout and stolen devices: logout revokes the refresh token family server-side; “sign out everywhere” and device revocation do the same per user/device. Because access tokens are short, they die within minutes.
  • High-risk actions: payments and changing contact details require a fresh authentication (auth_time/acr claims checked by the API, step-up if too old) or token introspection, so even a valid short-lived token isn’t enough.
  • Server checks on every request: signature, issuer, audience, expiry, scopes, and object-level ownership in queries.
  • Public clients: authorization code + PKCE in the system browser, claimed redirect URIs; no implicit or password grants (OAuth 2.1). Services: client credentials with narrow scopes.
  • Object-level authorization in every query (findByIdAndOwnerId), 404 for others’ objects; it’s the top API vulnerability (BOLA/IDOR).
  • Tokens: Keychain / Keystore-backed storage; rotating refresh tokens with reuse detection; DPoP for sender-constrained tokens; step-up or freshness checks for high-risk actions.

Check this part

  1. Which OAuth2 flow should a mobile app use to log users in?

  2. An endpoint requires login but loads orders by id only. What can a customer do?

  3. A refresh token is presented a second time after already being rotated. What should the server do?

Mobile apps log in with OAuth 2 authorization code and PKCE in the system browser, never with the user's password in a client secret. Endpoint rules aren't enough: every query must also check that the object belongs to the caller, or users can read each other's data by changing an id.

Key takeaways

  • A SecurityFilterChain decides which URLs are public, which need login and which need a scope; the first matching rule wins.
  • 401 means “who are you?”, 403 means “you’re not allowed”.
  • JWT access tokens are signed, readable and checked without a database; keep them short-lived.
  • Take the user’s identity from the token and include it in every query, so users can’t read each other’s data.
  • Order rules specific → general, deny by default, and test each rule per role.
  • Validate aud as well as signature, issuer and expiry; map IdP claims to authorities.
  • Short access tokens, rotating refresh tokens with reuse detection, step-up for risky operations.
  • Object-level authorization is your code’s job; authorization code + PKCE for apps, client credentials for services.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.