Stateless REST API Security with JWT in Spring Boot

Introduction

This tutorial secures a Spring Boot REST API with JSON Web Tokens (JWT), without any server-side session. We will see why APIs often prefer tokens to the session cookies of the form login tutorialClick to read tutorial: Spring Security Form Login and Session Authentication, and take a real JWT apart into its header, claims and signature. We will then build an application that issues its own RSA-signed tokens from a /token endpoint, and accepts them on its API through Spring Security's resource server support, turning the token's scope claim into authorities. Ten real requests show a token being issued and used, a missing scope refused with 403, and a malformed and an expired token refused with 401. Lastly, we will look at what JWTs are good and bad at.

Sessions or tokens?

With form login, the server remembers who is logged in, in an HTTP session, and the browser sends a session cookie. That works well for web pages. For APIs it has drawbacks: the session lives in one server's memory, so several servers must share it; cookies suit browsers, not mobile apps or other services; and cookie-based APIs need CSRF protection.

A stateless API remembers nothing between requests. The client logs in once and receives a token, a string it sends with each request in an Authorization: Bearer <token> header. A JWT is a token that carries the user's identity and permissions inside it, with a signature that proves the server issued it. Any server holding the public key can check it, without a database lookup or a shared session - Structure of a JWT - a Base64URL header with the signing algorithm, a payload of claims such as sub, exp and scope, and an RS256 signature made with the server's private key

The example

The application issues and checks its own tokens, a common setup for a single service. It needs the starter spring-boot-starter-security-oauth2-resource-server, which brings Spring Security's JWT support and the Nimbus JOSE library. The resource server tutorialClick to read tutorial: Spring Security OAuth2 Resource Server with JWT shows the other setup, where a separate authorization server issues the tokens.

The configuration creates an RSA key pair, a JwtEncoder that signs with the private key, and a JwtDecoder that verifies with the public key. One chain accepts two kinds of credentials: HTTP Basic, used only to get a token from /token, and bearer tokens for everything else -
JwtConfig.java - a key pair, a JWT encoder and decoder, and the security rules
//JwtConfig.java
package com.javabrahman.security.jwt;

import static org.springframework.security.config.Customizer.withDefaults;

import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class JwtConfig {

  private final KeyPair keys = generateRsaKeys();   // a new key pair at every start, for this example

  @Bean
  SecurityFilterChain chain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers(HttpMethod.POST, "/token").authenticated()          // username and password
            .requestMatchers(HttpMethod.GET, "/api/**").hasAuthority("SCOPE_books:read")
            .requestMatchers(HttpMethod.POST, "/api/**").hasAuthority("SCOPE_books:write")
            .anyRequest().authenticated())
        .httpBasic(withDefaults())                                               // only used for /token
        .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()))              // Authorization: Bearer <jwt>
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .csrf(csrf -> csrf.disable());
    return http.build();
  }

  @Bean
  JwtEncoder jwtEncoder() {
    RSAKey key = new RSAKey.Builder((RSAPublicKey) keys.getPublic()).privateKey((RSAPrivateKey) keys.getPrivate()).build();
    return new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(key)));
  }

  @Bean
  JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withPublicKey((RSAPublicKey) keys.getPublic()).build();
  }

  @Bean
  UserDetailsService users() {
    return new InMemoryUserDetailsManager(
        User.withUsername("ann").password("{noop}ann-pass").authorities("books:read").build(),
        User.withUsername("raj").password("{noop}raj-pass").authorities("books:read", "books:write").build());
  }

  private static KeyPair generateRsaKeys() {
    try {
      KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
      generator.initialize(2048);
      return generator.generateKeyPair();
    } catch (Exception e) {
      throw new IllegalStateException(e);
    }
  }
}
  • oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults())) adds the BearerTokenAuthenticationFilter, which reads the bearer token and passes it to the JwtDecoder bean.
  • The rules use SCOPE_books:read and SCOPE_books:write. By default, Spring Security turns each word of a token's scope claim into an authority with the prefix SCOPE_.
  • Stateless sessions and no CSRF protection, as for the API chain in Configuring SecurityFilterChainClick to read tutorial: Configuring SecurityFilterChain in Spring Security: a token in a header is not sent automatically by browsers, so a forged cross-site request cannot carry it.
  • The key pair is generated at every start, so tokens stop working after a restart. A real service loads its key from a key store or a secrets manager.
The token endpoint turns the authenticated user into a set of claims and signs them. The ageSeconds parameter exists only to create an already-expired token for the demonstration -
TokenController.java - issuing a signed JWT
//TokenController.java
package com.javabrahman.security.jwt;

import java.time.Instant;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TokenController {
  private final JwtEncoder encoder;

  public TokenController(JwtEncoder encoder) {
    this.encoder = encoder;
  }

  @PostMapping("/token")
  public Map<String, Object> token(Authentication user, @RequestParam(defaultValue = "600") long seconds,
                                   @RequestParam(defaultValue = "0") long ageSeconds) {   // ageSeconds: to demonstrate expiry
    Instant now = Instant.now().minusSeconds(ageSeconds);
    String scope = user.getAuthorities().stream()
        .map(GrantedAuthority::getAuthority)
        .filter(a -> a.startsWith("books:"))            // the permissions, not FACTOR_PASSWORD
        .sorted()
        .collect(Collectors.joining(" "));
    JwtClaimsSet claims = JwtClaimsSet.builder()
        .issuer("javabrahman-demo")
        .subject(user.getName())
        .issuedAt(now)
        .expiresAt(now.plusSeconds(seconds))
        .claim("scope", scope)
        .build();
    JwsHeader header = JwsHeader.with(SignatureAlgorithm.RS256).build();
    String token = encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
    return Map.of("access_token", token, "token_type", "Bearer", "expires_in", seconds);
  }
}
BookApiController.java - endpoints that need a token
//BookApiController.java
package com.javabrahman.security.jwt;

import java.util.List;
import java.util.Map;
import org.springframework.http.ResponseEntity;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api")
public class BookApiController {

  @GetMapping("/books")
  public List<String> books() {
    return List.of("Effective Java", "Clean Code");
  }

  @PostMapping("/books")
  public ResponseEntity<String> add(@RequestBody String title) {
    return ResponseEntity.status(201).body("added: " + title);
  }

  @GetMapping("/me")
  public Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) {
    return Map.of("header", jwt.getHeaders(), "claims", jwt.getClaims());
  }
}
JwtApp.java
//JwtApp.java
package com.javabrahman.security.jwt;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class JwtApp {

  public static void main(String[] args) {
    SpringApplication.run(JwtApp.class, args);
  }
}

The token flow

Before the real requests, here is the path a token takes: issued once in exchange for a password, then checked on every request - JWT flow in Spring Security - the client posts its password to /token and receives a signed JWT, then sends it as a bearer token, which BearerTokenAuthenticationFilter verifies and turns into SCOPE_ authorities

The requests

Ann (YW5u...) may only read; raj (cmFq...) may read and write. Each Bearer header carries the token from the latest /token response; in the request lines the tokens are shortened to their first 20 and last 8 characters -  OUTPUT of the HTTP requests
> curl -i -X POST http://localhost:8080/token -H "Authorization: Basic YW5uOmFubi1wYXNz"
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
Content-Length: 546
Content-Type: application/json
Expires: 0

{"token_type":"Bearer","expires_in":600,"access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJqYXZhYnJhaG1hbi1kZW1vIiwic3ViIjoiYW5uIiwiZXhwIjoxNzkzOTkyOTEwLCJpYXQiOjE3OTM5OTIzMTAsInNjb3BlIjoiYm9va3M6cmVhZCJ9.LrKAVcXASC4j-lAozgniWGVPHULKhUQmRl6AA_nU5VgWTtmolib69ZL5dDn1nkKGNjtFcrdDhp8MubS18aGeAOu-rcqsiN1Dtxa0acKkn6ccYMnLxhSieDxI6auQUrxA7ggdPzQV6QIoOLp90hRlHow2mIt1CF2vlAgZoW0S79KALnhVYHme8ZHetf-AOPjdYgcpvWYaOsVItA_b2-MZjzELw_tSvmvqSMMj07LGAlPvCAGSY1AzsrAPaoasOS7Qz33F4ToDyu49fRCPAM6YE4RcLY1HwIF7SnSXFdldptcy4vdVYzx5XNo6LWHYY0z0awDhz8KaW02m2Ox7tQqqfQ"}

> curl -i http://localhost:8080/api/books -H "Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...x7tQqqfQ"
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
Content-Length: 31
Content-Type: application/json
Expires: 0

["Effective Java","Clean Code"]

> curl -i http://localhost:8080/api/me -H "Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...x7tQqqfQ"
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
Content-Length: 153
Content-Type: application/json
Expires: 0

{"claims":{"iss":"javabrahman-demo","sub":"ann","exp":"2026-11-06T19:21:50Z","iat":"2026-11-06T19:11:50Z","scope":"books:read"},"header":{"alg":"RS256"}}

> curl -i -X POST http://localhost:8080/api/books -H "Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...x7tQqqfQ" -H "Content-Type: text/plain" -d 'Refactoring'
HTTP/1.1 403
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
Content-Length: 0
Expires: 0

> curl -i http://localhost:8080/api/books
HTTP/1.1 401
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0
Expires: 0

> curl -i http://localhost:8080/api/books -H "Authorization: Bearer not-a-real-token"
HTTP/1.1 401
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Malformed token", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"
Content-Length: 0
Expires: 0

> curl -i -X POST http://localhost:8080/token?ageSeconds=900 -H "Authorization: Basic YW5uOmFubi1wYXNz"
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
Content-Length: 546
Content-Type: application/json
Expires: 0

{"token_type":"Bearer","expires_in":600,"access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJqYXZhYnJhaG1hbi1kZW1vIiwic3ViIjoiYW5uIiwiZXhwIjoxNzkzOTkyMDExLCJpYXQiOjE3OTM5OTE0MTEsInNjb3BlIjoiYm9va3M6cmVhZCJ9.Gyfesq5wP9ZE_2B8F4tjXm5eZ38d2BKnNYyBHN5W_pRNgnr1S3dHp5jE8eMpSukOCbOEbe-VtGIjw-mRYbdnTYAlJOuPd--tdxtnDpqiJKJ2oK0rkNBd2vsCdITtqe5-yR3l_qRhidm5bRIdUiRxg_WYb5UaVFaZUdBwJK44DC2wP033eKOtTr2q4iBCsGbpusSkhhMD_ECvprYRMGx5JdnmcAGjV6aIODV8N1yB0YPNa5VgNjwzeQFpJlpaaGRPdgRIQE8EePbejWM4MhOXE2INXaZJgD61daDd-5vFTA0DFIx9rgmMZEHNsYZRR_QKRWqe9PdrBMwFoTBOiVN6ow"}

> curl -i http://localhost:8080/api/books -H "Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...BOiVN6ow"
HTTP/1.1 401
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Jwt expired at 2026-11-06T19:06:51Z", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"
Content-Length: 0
Expires: 0

> curl -i -X POST http://localhost:8080/token -H "Authorization: Basic cmFqOnJhai1wYXNz"
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
Content-Length: 562
Content-Type: application/json
Expires: 0

{"token_type":"Bearer","expires_in":600,"access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJqYXZhYnJhaG1hbi1kZW1vIiwic3ViIjoicmFqIiwiZXhwIjoxNzkzOTkyOTExLCJpYXQiOjE3OTM5OTIzMTEsInNjb3BlIjoiYm9va3M6cmVhZCBib29rczp3cml0ZSJ9.Xqdn7guXCzgev-1qPOyPTLU5bQT7Q9WVOnLXGGdEjrVq3X5qiS8POU36NnyMiQGdtl6fzdI7VqjJEY7s4dwCq84XSlW5VCXllkxY-PiHcmmJB4y4hqAdkzTV1xXkmIeoX9diMNmNxX9VaQVgPa6nV3CEiZnMc9N6pmd6ftENCrPMMQkxXX6KFW2-V4s_cX4IadCjFMAjOm94RjNDsYDFAUXVQeOopQfJPlAlAWoK1esCj-Too0D7OCtQVLuc_hls2Q9Hk9WLp3j-s9XocKRUrtAdwgnno68TtSAR6oADxMTl4g6ufuTCn1E8p4BBDLQeoJrci_vEEC-_1NFJH1HMWw"}

> curl -i -X POST http://localhost:8080/api/books -H "Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...FJH1HMWw" -H "Content-Type: text/plain" -d 'Refactoring'
HTTP/1.1 201
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
Content-Length: 18
Content-Type: text/plain; charset=UTF-8
Expires: 0

added: Refactoring

Explanation of the responses

  • The token: /token answered with an access_token of three dot-separated parts, starting with eyJhbGciOiJSUzI1NiJ9, which is the Base64URL encoding of {"alg":"RS256"}. No session cookie was set.
  • Using it: GET /api/books with the token: 200. /api/me shows what the server read from it: the header {"alg":"RS256"}, and the claims iss, sub (ann), iat, exp, ten minutes later, and scope "books:read". The controller received them as a Jwt object through @AuthenticationPrincipal.
  • Not enough scope: ann's POST got 403 with WWW-Authenticate: Bearer error="insufficient_scope". The token was valid; it just does not grant books:write. The error codes come from RFC 6750, the standard for bearer tokens.
  • No token: 401, but with WWW-Authenticate: Basic. This chain has two entry points, and for a request without any credentials HTTP Basic's answered. An API used only with tokens would leave out httpBasic(), and the 401 would then say Bearer.
  • A malformed token: 401, error="invalid_token", "Malformed token". The header also points to /.well-known/oauth-protected-resource, the address of a "protected resource metadata" document as defined by RFC 9728, where clients can find out how to get a token for this API.
  • An expired token: the token issued 900 seconds in the past, valid for 600, was refused: "Jwt expired at ...", five minutes before the request. The signature was perfect; the expiry alone decided. The decoder allows 60 seconds of clock difference between servers by default.
  • Raj's token carries scope "books:read books:write", so his POST got 201.

Choosing claims and lifetimes

  • Keep tokens short-lived, minutes rather than days. A JWT cannot be withdrawn before it expires without extra machinery, such as a list of revoked token ids that every server checks, which gives up part of the statelessness. Long sessions are usually built from a short-lived access token plus a refresh token, which is checked against the server's records when a new access token is needed.
  • Put permissions in, not secrets: the payload is readable by anyone who has the token, as /api/me showed.
  • Validate more than the signature when tokens come from elsewhere: the issuer (iss) and the audience (aud) as well, so that a token issued for another service is not accepted by this one. The resource server tutorial configures both.
  • Roles from claims: a token can carry roles in its own claim. A JwtAuthenticationConverter maps it to authorities, for example to ROLE_ authorities for hasRole().

Where JWTs fit, and where they do not

  • Good fits: APIs called by mobile apps, single-page applications and other services; several servers or microservices that must all check the same tokens without sharing sessions.
  • Poor fits: a classic server-rendered web application, where a session cookie is simpler and can be ended at once on logout.
  • In the browser, a token kept in JavaScript-readable storage can be stolen by an XSS bug, unlike an HttpOnly cookie. Many single-page applications therefore keep tokens on the server side, in a "backend for frontend" that talks to the browser with a session cookie.
  • Issuing your own tokens, as here, suits one service with its own users. When several applications share users, or users log in with Google or GitHub, a dedicated authorization server issues the tokens, as shown in OAuth2 login and OpenID ConnectClick to read tutorial: Spring Security OAuth2 Login and OpenID Connect.

Summary

In this tutorial we secured a REST API with JWTs and no server-side session. A JWT is a Base64URL header, a set of claims and a signature: readable by anyone, but impossible to change without the private key. The application created an RSA key pair, signed tokens with a JwtEncoder at /token in exchange for a password, and checked them with a JwtDecoder through oauth2ResourceServer().jwt(), mapping the scope claim to SCOPE_ authorities. Real requests showed a valid token accepted, a missing scope refused with 403 insufficient_scope, and malformed and expired tokens refused with 401 invalid_token, each explained in the WWW-Authenticate header. We also saw why tokens should be short-lived, and where JWTs fit. The next tutorial lets users log in with an external identity provider: OAuth2 login and OpenID ConnectClick to read tutorial: Spring Security OAuth2 Login and OpenID Connect.