1
0
Files
jwt-auth-demo/docs/05-hs256-vs-rs256.md
asmhatre 4a8dab6739 Spring Security 7.1 JWT authentication on Spring Boot 4.1
Runnable companion for https://ankurm.com/spring-security-7-1-jwt-authentication-guide/

- login -> token issue -> OncePerRequestFilter -> SecurityContext, end to end
- HS256 and RS256 variants (RS256 publishes a real JWKS endpoint)
- the same API secured by the built-in oauth2ResourceServer().jwt(), for comparison
- 11 documentation chapters under docs/, interlinked with the code
- docs/output/ is real captured output, regenerated by scripts/run-all.sh
- 13 passing tests pinning the 401-vs-403 contract and the CSRF failure

Verified against Spring Boot 4.1.1, Spring Security 7.1.1, JDK 25.0.4.1.
2026-08-22 06:34:43 +00:00

6.6 KiB
Raw Blame History

05 — HS256 vs RS256

← CSRF vs permitAll · next: SecurityContext →

The distinction that matters

HS256 RS256
key one shared secret private/public pair
who can verify anyone who can sign anyone at all
who can sign anyone who can verify only the private-key holder
signature size 32 bytes 256 bytes (RSA-2048)
sign cost ~microseconds ~100× HMAC
verify cost ~microseconds ~10× HMAC
key distribution copy the secret everywhere publish a JWKS URL

The performance column is not the deciding one. The deciding question is whether the set of services that verify tokens is the same as the set you trust to mint them.

With HS256 the answer is forced: verifying requires the signing secret, so every verifier is also an issuer. One compromised read-only reporting service can mint an admin token. If the answer is "no", you need RS256 (or ES256), and no amount of secret rotation substitutes.

HS256

this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");

@Bean JwtEncoder jwtEncoder() {
    return NimbusJwtEncoder.withSecretKey(this.secretKey)
            .algorithm(MacAlgorithm.HS256)
            .build();
}

@Bean JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withSecretKey(this.secretKey)
            .macAlgorithm(MacAlgorithm.HS256)
            .build();
}

Three things to notice.

The builder method is algorithm(..), not jwsAlgorithm(..). NimbusJwtEncoder's SecretKeyJwtEncoderBuilder (added in Spring Security 7.0) exposes exactly two methods: algorithm(MacAlgorithm) and jwkPostProcessor(Consumer<OctetSequenceKey.Builder>). The decoder side, confusingly, does use macAlgorithm(..) / signatureAlgorithm(..).

The secret must be ≥ 256 bits. Nimbus enforces the JWA rule that an HMAC key is at least as long as its digest; a shorter one throws KeyLengthException at encoder construction, not at first request. Hs256KeyConfig fails fast with a clearer message. A short secret is also brute-forceable offline — the attacker has the ciphertext, the plaintext, and unlimited attempts.

A passphrase is not a key. "changeit-changeit-changeit-change" is 32 bytes and passes the length check while having perhaps 40 bits of entropy. Generate it:

openssl rand -base64 48

RS256

@Bean JwtEncoder jwtEncoder() {
    return NimbusJwtEncoder.withKeyPair(this.publicKey, this.privateKey)
            .algorithm(SignatureAlgorithm.RS256)
            .jwkPostProcessor(jwk -> jwk.keyID("demo-rsa-2026-08"))
            .build();
}

@Bean JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withPublicKey(this.publicKey)
            .signatureAlgorithm(SignatureAlgorithm.RS256)
            .build();
}

There is no keyId(..) method on the builder. The kid is set by post-processing the Nimbus JWK builder — jwkPostProcessor(jwk -> jwk.keyID(...)). Without a kid, key rotation is impossible: the verifier cannot tell which of two published keys to try.

Publishing the public half

Rs256KeyConfig.JwkSetEndpoint serves a real JWK Set. From rs256-demo.txt:

{
  "keys": [
    {
      "kty": "RSA",
      "e": "AQAB",
      "kid": "demo-rsa-2026-08",
      "n": "5NEDQPQW0Gz6iR5-UNl7J7660_Psd5q1f5VamK9KTS9f6YhPPIG8mfi6zWe8Xmxx..."
    }
  ]
}

n and e only — the public modulus and exponent. A private key would additionally carry d, p, q. Audit for those letters before exposing a JWKS endpoint: leaking d hands over the signing key.

A separate resource server then needs no key material at all:

@Bean JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withJwkSetUri("https://issuer.example.com/.well-known/jwks.json")
            .build();
}

or, in application.yaml:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://issuer.example.com

issuer-uri fetches OIDC discovery at startup and fails the context if the issuer is unreachable. jwk-set-uri fetches lazily. In an environment where the issuer boots alongside the resource server, issuer-uri produces a startup-ordering dependency that jwk-set-uri does not.

Rotation

RS256 rotates without downtime because the verifier can hold several keys:

  1. Generate a new pair with a new kid.
  2. Publish both public keys in the JWK Set.
  3. Wait for caches to refresh (NimbusJwtDecoder caches, and honours Cache-Control).
  4. Switch the issuer to sign with the new kid.
  5. Wait one full access-token TTL, so no live token references the old key.
  6. Remove the old key from the JWK Set.

HS256 has no equivalent. The secret is symmetric, so steps 2 and 4 are the same step, and every token signed with the old secret is invalid the moment you rotate. The workarounds are a decoder that tries both secrets during a window, or a hard cutover that logs everyone out.

Algorithm confusion — pin the algorithm

The classic JWT attack: take an RS256 token, change the header to alg: HS256, and sign it with the public key as the HMAC secret. A verifier that reads alg from the token and looks up "the key" will verify it, because the public key is public.

Spring Security is not vulnerable by default — NimbusJwtDecoder.withPublicKey(...) defaults to RS256 and will not switch families. But pin it anyway, because the intent should be in the code rather than in a default:

NimbusJwtDecoder.withPublicKey(publicKey)
        .signatureAlgorithm(SignatureAlgorithm.RS256)
        .build();

The related alg: none attack is a non-issue here — Nimbus refuses unsigned JWTs for a configured verifier — but the same principle applies: never let the token choose how it is verified.

Which to pick

HS256 — one service issues and consumes its own tokens; the secret never leaves that deployment unit; you want the smallest tokens and the cheapest verification. A monolith.

RS256 / ES256 — more than one service verifies; a third party verifies; you need rotation without a flag day; compliance requires the signing key in an HSM or KMS. Any real microservice estate.

ES256 deserves a mention: same asymmetric properties as RS256 with 64-byte signatures instead of 256, and Spring Security supports it out of the box via NimbusJwtEncoder.withKeyPair(ECPublicKey, ECPrivateKey). If you are choosing today and your clients can handle EC, it is the better default.