Issuing Tokens with JwtEncoder
Sign your own access tokens with JwtEncoder and an RSA key pair when one API owns its users, and verify them with the matching JwtDecoder.
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(issuer).subject(userId)
.issuedAt(now).expiresAt(now.plus(ttl))
.claim("scope", "orders:read").build();
String token = encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();Sometimes there is no separate identity provider: one API, its own user table, a first-party web or mobile app. Spring Security can sign tokens for that case with JwtEncoder, and the same app verifies them as a resource server. Both live in org.springframework.security.oauth2.jwt, which comes with spring-boot-starter-oauth2-resource-server.
Keys first
Sign with an RSA key pair (RS256). The private key signs; the public key verifies and can be published to other services without letting them forge tokens. Generate the pair once, keep the private key in a secret store, and mount it at runtime. Never commit it, and never generate a fresh pair at every start-up in production: every restart would invalidate every token, and two instances behind a load balancer would reject each other's tokens.
@Bean
JwtEncoder jwtEncoder() {
return NimbusJwtEncoder.withKeyPair(publicKey, privateKey).build();
}
@Bean
JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(publicKey).build();
}Declaring a JwtDecoder bean is enough for oauth2ResourceServer(o -> o.jwt(...)) to use it; no issuer-uri is needed. A decoder from withPublicKey checks only the signature, exp and nbf, so add issuer and audience validators with setJwtValidator, as the full example does.
Building the claims
A token request authenticates the user with an AuthenticationManager, then builds a JwtClaimsSet: issuer, subject, audience, issued-at, expiry and the permissions the resource server will check. JwtEncoderParameters.from(header, claims) wraps them, and encode returns a Jwt whose getTokenValue() is the compact string to return.
Make the permissions match the rules that read them. A user loaded from your user table usually has ROLE_USER or ROLE_ADMIN, never SCOPE_orders:read, so copying only SCOPE_ authorities into the token gives ordinary users an empty scope and a 403 on every hasAuthority("SCOPE_orders:read") rule. The example maps roles to scopes in one explicit table, writes them to scope, and also writes the role names to a roles claim. Its JwtAuthenticationConverter turns those back into SCOPE_ and ROLE_ authorities, so both kinds of rule work. Reuse the same helper when refreshing tokens.
Pass a JwsHeader naming the algorithm, and a kid if you plan to rotate keys. A kid lets a verifier pick the right public key while old and new keys overlap. The builder gives its key a kid derived from the key itself; to use your own, set it on the key with jwkPostProcessor(jwk -> jwk.keyID("2026-10")). A header whose kid or algorithm matches no key fails with "Failed to select a JWK signing key".
HMAC as the simpler option
For a single service that both signs and verifies, a shared secret with HS256 is acceptable: NimbusJwtEncoder.withSecretKey(key) and NimbusJwtDecoder.withSecretKey(key). The secret must be at least 256 bits of real randomness, not a memorable string, and it must never leave that service, because any holder can mint tokens.
Mistakes that hurt in production
- Long expiries such as a day or a week because refresh was not built. A stolen token then works for that long.
- Putting the whole user record, including email and internal flags, into claims. Keep claims to what authorisation needs.
- Returning different errors for unknown user and wrong password, which lets attackers enumerate accounts.
- No rate limit on the token endpoint, which invites password guessing.
Once you need single sign-on across several apps, third-party clients, consent screens or MFA, stop extending this and move to a real authorization server.
Example
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
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.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtAudienceValidator;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
class TokenConfig {
// app.jwt.public-key=file:/run/secrets/jwt.pub, app.jwt.private-key=file:/run/secrets/jwt.key
// Spring Security converts a PEM resource location into the key type for @Value.
("${app.jwt.public-key}")
RSAPublicKey publicKey;
("${app.jwt.private-key}")
RSAPrivateKey privateKey;
JwtEncoder jwtEncoder() {
// The kid must match the one in the JwsHeader, or no signing key is selected.
return NimbusJwtEncoder.withKeyPair(publicKey, privateKey)
.jwkPostProcessor(jwk -> jwk.keyID("2026-10"))
.build();
}
JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey).build();
// withPublicKey only checks exp and nbf by default; add the issuer and audience.
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer("https://api.example.com"),
new JwtAudienceValidator("orders-api")));
return decoder;
}
AuthenticationManager authenticationManager(UserDetailsService users, PasswordEncoder passwords) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(users);
provider.setPasswordEncoder(passwords);
return new ProviderManager(provider);
}
SecurityFilterChain api(HttpSecurity http) throws Exception {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(TokenConfig::authorities);
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/token").permitAll()
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders:read")
.requestMatchers(HttpMethod.POST, "/api/orders/**").hasAuthority("SCOPE_orders:write")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(converter)))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable())
.build();
}
// Reads back what TokenClaims wrote: scope -> SCOPE_x (the default), roles -> ROLE_x.
static Collection<GrantedAuthority> authorities(Jwt jwt) {
List<GrantedAuthority> result = new ArrayList<>(new JwtGrantedAuthoritiesConverter().convert(jwt));
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.forEach(role -> result.add(new SimpleGrantedAuthority("ROLE_" + role)));
}
return result;
}
}
// One place that decides which claims a user's authorities turn into. Use it for the
// login token and for every refreshed token, so both carry the same permissions.
final class TokenClaims {
// Users carry ROLE_ authorities; the API's rules check SCOPE_ authorities. Map explicitly.
private static final Map<String, List<String>> SCOPES_BY_ROLE = Map.of(
"ROLE_USER", List.of("orders:read"),
"ROLE_SUPPORT", List.of("orders:read", "orders:write"),
"ROLE_ADMIN", List.of("orders:read", "orders:write"));
private TokenClaims() {
}
static JwtClaimsSet forUser(String subject, Collection<? extends GrantedAuthority> authorities, Duration ttl) {
TreeSet<String> scopes = new TreeSet<>();
List<String> roles = new ArrayList<>();
for (GrantedAuthority authority : authorities) {
String name = authority.getAuthority();
scopes.addAll(SCOPES_BY_ROLE.getOrDefault(name, List.of()));
if (name.startsWith("ROLE_")) {
roles.add(name.substring("ROLE_".length()));
}
}
Instant now = Instant.now();
return JwtClaimsSet.builder()
.issuer("https://api.example.com")
.id(UUID.randomUUID().toString()) // jti, so a token can be revoked individually
.subject(subject)
.audience(List.of("orders-api"))
.issuedAt(now)
.expiresAt(now.plus(ttl))
.claim("scope", String.join(" ", scopes))
.claim("roles", roles)
.build();
}
}
record LoginRequest(String username, String password) {
}
record TokenResponse(String access_token, String token_type, long expires_in) {
}
public class TokenController {
private static final Duration TTL = Duration.ofMinutes(10);
private final AuthenticationManager authenticationManager;
private final JwtEncoder encoder;
TokenController(AuthenticationManager authenticationManager, JwtEncoder encoder) {
this.authenticationManager = authenticationManager;
this.encoder = encoder;
}
("/auth/token")
TokenResponse token( LoginRequest login) {
// Throws BadCredentialsException (a 401) for an unknown user or a wrong password alike.
Authentication auth = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(login.username(), login.password()));
JwtClaimsSet claims = TokenClaims.forUser(auth.getName(), auth.getAuthorities(), TTL);
JwsHeader header = JwsHeader.with(SignatureAlgorithm.RS256).keyId("2026-10").build();
String token = encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
return new TokenResponse(token, "Bearer", TTL.toSeconds());
}
}
When to use it
- A small SaaS backend with its own user table issues ten-minute tokens to its React front end.
- A mobile game server signs tokens for players and exposes a JWK Set so its leaderboard service can verify them.
- An internal tool that cannot depend on the company identity provider during an outage issues short-lived tokens itself.
More examples
Generating the RSA key pair
<code>openssl genpkey</code> writes PKCS#8 (<code>BEGIN PRIVATE KEY</code>), which is the format the converter reads; an older <code>BEGIN RSA PRIVATE KEY</code> file will not load. Mount both files into the container and point <code>app.jwt.public-key</code> and <code>app.jwt.private-key</code> at them with <code>file:</code> locations.
# 2048-bit RSA private key in PKCS#8 PEM, as Spring Security's key converter expects
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt.key
# The matching public key in X.509 SubjectPublicKeyInfo PEM
openssl pkey -in jwt.key -pubout -out jwt.pub
# Store jwt.key in your secret manager; jwt.pub can be shared with other services.
An HMAC-signed variant for a single service
The secret comes from an environment variable and is decoded from base64 so it carries full random bytes. A secret shorter than 256 bits is rejected by the signing library. Use this only when the signer and the verifier are the same service; as soon as a second service needs to verify, switch to a key pair.
import java.util.Base64;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
@Configuration
public class HmacTokenConfig {
private final SecretKey key;
// app.jwt.secret=${JWT_SECRET}, a base64 value of at least 32 random bytes,
// for example from: openssl rand -base64 32
HmacTokenConfig(@Value("${app.jwt.secret}") String secret) {
this.key = new SecretKeySpec(Base64.getDecoder().decode(secret), "HmacSHA256");
}
@Bean
JwtEncoder jwtEncoder() {
return NimbusJwtEncoder.withSecretKey(key).algorithm(MacAlgorithm.HS256).build();
}
@Bean
JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withSecretKey(key).macAlgorithm(MacAlgorithm.HS256).build();
}
static JwtEncoderParameters hs256(JwtClaimsSet claims) {
// The builder's default header is already HS256; an explicit header must say so too,
// because an RS256 header finds no matching key.
return JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims);
}
}
Publishing the public key as a JWK Set
Other services can now set <code>spring.security.oauth2.resourceserver.jwt.jwk-set-uri</code> to this URL and verify your tokens without being handed the key file. The builder only ever sees the public key, so nothing secret is exposed. Permit this path in the security chain, and during a rotation publish both the old and the new key.
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.jwk.RSAKey;
import java.security.interfaces.RSAPublicKey;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class JwksController {
private final Map<String, Object> jwks;
JwksController(@Value("${app.jwt.public-key}") RSAPublicKey publicKey) {
RSAKey jwk = new RSAKey.Builder(publicKey)
.keyID("2026-10") // must match the kid in the JwsHeader
.keyUse(KeyUse.SIGNATURE)
.build();
this.jwks = new JWKSet(jwk).toJSONObject(); // public parameters only
}
@GetMapping("/.well-known/jwks.json")
Map<String, Object> jwks() {
return jwks;
}
}
Discussion