Why Signing Key Management Matters in Spring Boot
Spring Boot makes JWT authentication straightforward with spring-boot-starter-oauth2-resource-server or Nimbus JOSE + JWT, but where signing keys live, how they rotate, and how verifiers pick the right key is where teams stumble. A leaked HMAC secret lets anyone forge tokens; a stale key after rotation breaks every API call until you redeploy.
This guide covers multi-key signing with kid, verification filters that resolve keys dynamically, YAML for local dev, secret-manager integration, and rotation without downtime. If you are choosing algorithms, start with our HS256 vs RS256 comparison.
Threat Model: What You Are Defending Against
Treat signing keys as tier-zero credentials:
- Secret leakage from committed
.envfiles, CI logs, heap dumps, or Actuator endpoints exposing config. - Offline forgery when an attacker captures a JWT and brute-forces a weak secret. Generate keys with the JWT Secret Generator—never reuse passwords as JWT secrets.
- Stale verifiers during rotation: auth signs with key B while resource servers still trust only key A.
- Algorithm confusion where verifiers accept
noneor misuse RSA public keys with HS256.
Assume breach is possible: short token TTL, scheduled rotation, auth-failure auditing, and separate keys per environment.
Multi-Key Signing with `kid` in Java
The JWT header kid tells verifiers which key signed the token. Keep active keys in memory, sign with the primary, and verify against any key in the acceptance window.
public class MultiKeyJwtService {
public record SigningKey(String kid, byte[] secret, boolean primary) {}
private final Map<String, SigningKey> keysByKid;
private final String primaryKid;
public MultiKeyJwtService(List<SigningKey> keys) {
keysByKid = new LinkedHashMap<>();
keys.forEach(k -> keysByKid.put(k.kid(), k));
primaryKid = keys.stream().filter(SigningKey::primary).findFirst().orElseThrow().kid();
}
public String sign(String subject) throws JOSEException {
SigningKey key = keysByKid.get(primaryKid);
JWTClaimsSet claims = new JWTClaimsSet.Builder()
.subject(subject)
.expirationTime(new Date(System.currentTimeMillis() + 3600_000))
.build();
SignedJWT jwt = new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.HS256).keyID(key.kid()).build(), claims);
jwt.sign(new MACSigner(key.secret()));
return jwt.serialize();
}
public JWTClaimsSet verify(String token) throws Exception {
SignedJWT jwt = SignedJWT.parse(token);
SigningKey key = keysByKid.get(jwt.getHeader().getKeyID());
if (key == null || !jwt.verify(new MACVerifier(key.secret()))) {
throw new SecurityException("Invalid token");
}
return jwt.getJWTClaimsSet();
}
}For RS256, use RSASSASigner/RSASSAVerifier with PEM keys. Asymmetric signing scales when many services verify but only one auth service signs.
Verification Filter Concepts in Spring Security
Resource servers should not hardcode one secret in a @Bean. The flow:
1. Extract the bearer token from Authorization.
2. Parse the JWT header without trusting claims.
3. Resolve the verification key by kid from a key set refreshed from your secret store.
4. Verify signature, exp, iss, and aud before building Authentication.
With Spring Security 6, customize JwtDecoder for RSA or supply a decoder that selects MACVerifier by kid. Prefer the OAuth2 resource-server stack over custom OncePerRequestFilter unless you need legacy formats. Pin allowed algorithms and reject none.
YAML Configuration for Local Development
Keep secrets out of committed YAML:
jwt:
issuer: https://auth.example.com
keys:
- kid: key-2026-04
secret: ${JWT_SECRET_CURRENT}
primary: true
- kid: key-2026-03
secret: ${JWT_SECRET_PREVIOUS}
primary: false
accepted-algorithms:
- HS256In @ConfigurationProperties, validate at startup: one primary key, minimum 32-byte secrets for HS256, and no empty placeholders. Fail fast rather than issuing tokens with null material.
JJWT 0.12 Service Pattern (Alternative to Nimbus)
Teams that prefer JJWT can mirror the same Base64 env secret pattern:
@Service
public class JwtService {
@Value("${jwt.secret}")
private String secretBase64;
private SecretKey getSigningKey() {
byte[] keyBytes = Base64.getDecoder().decode(secretBase64);
return Keys.hmacShaKeyFor(keyBytes);
}
public String generateToken(String userId) {
return Jwts.builder()
.subject(userId)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 15 * 60 * 1000))
.signWith(getSigningKey())
.compact();
}
public Claims validateToken(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
}Use JJWT 0.12+ (verifyWith, signWith(SecretKey)). Older 0.11 setSigningKey / setSubject APIs are deprecated. Keep jwt.secret as Base64 in the environment and decode to bytes — jjwt expects a byte-backed SecretKey for HMAC.
For multi-key rotation without downtime, keep a map of kid → secret (sometimes called a KidAwareKeyResolver): sign with the current kid, verify any known kid during the overlap window, then drop the retired key after max token TTL.
Secret Manager Integration
Production keys belong in managed stores, not plain files:
- AWS Secrets Manager / Parameter Store — fetch versioned JSON
{ "kid": "...", "secret": "base64..." }at startup; poll or subscribe to rotation events. - GCP Secret Manager / Azure Key Vault — same versioned payload pattern.
- HashiCorp Vault — mount under
secret/data/jwt/signing.
A @Scheduled refresh every 60–120 seconds keeps verifiers aligned without redeploying. Log key version changes at INFO; never log secrets. See the Java guide for generation and length guidance.
Key Rotation Without Downtime
Zero-downtime rotation uses an overlap window:
1. Add the new key to every verifier (old and new kid both accepted).
2. Promote the new key to primary on the auth service.
3. Wait for the longest access-token TTL plus clock-skew buffer.
4. Remove the old key from verifiers and the secret manager.
Clients need no changes—they pick up tokens with the new kid on next login or refresh. Test the runbook in staging with short TTLs.
Common Security Pitfalls
- One shared HMAC secret across all microservices — prefer RS256 with JWKS, or distribute secrets via audited stores.
- No
kidsupport — forces simultaneous deploys during rotation. - Logging tokens or secrets in filters and exception handlers.
- Weak dev keys in production — use the JWT Secret Generator locally and a manager in prod.
- Ignoring
issandaud— valid signatures still allow cross-service replay.
Production Checklist
- [ ] Primary and overlap keys with unique
kidvalues - [ ] Verifier resolves by
kid; unknownkidreturns 401 - [ ] Algorithms pinned (no
none, no wildcard) - [ ] Secrets from a manager; never in git
- [ ] Startup validation for length and single primary
- [ ] Rotation tested; old key removed after max token TTL
- [ ] Separate keys per environment
- [ ] Access token TTL ≤ 15 minutes
- [ ] Auth failures logged without token or secret values
Frequently Asked Questions
Should Spring Boot use HS256 or RS256 for JWT signing?
HS256 suits single services that sign and verify locally, but every verifier needs the raw secret. RS256 lets resource servers trust a JWKS URL while only the auth service holds the private key—usually better for microservices. Read our HS256 vs RS256 comparison.
How do I rotate JWT signing keys without logging users out?
Keep old and new keys in the verifier set during overlap. New logins get the new kid; existing tokens expire naturally. Remove the old key only after the maximum access-token lifetime passes.
Where should I store JWT signing secrets in Spring Boot production?
Not in committed application.yml. Use AWS, GCP, Azure, or Vault with runtime injection via Spring Cloud. Local dev uses .gitignored .env files. The JWT secret glossary covers why storage matters as much as length.
What happens if a JWT signing key is compromised?
Add a new key, promote it, invalidate long-lived refresh tokens if needed, and remove the compromised key after outstanding access tokens expire. Audit logs for the exposure window. Generate replacement material with the JWT Secret Generator before redeploying verifiers.
Should I store the JWT secret as plain text or Base64 in Spring Boot?
Prefer Base64 (or hex) in the environment variable and decode to bytes in JwtService / Nimbus. HMAC operates on raw key bytes; string encoding mistakes are a common source of "works in one service, fails in another" bugs.