toolhq.io

All posts
7 min readby Jameel Haider

HS256 vs RS256: choosing a JWT signing algorithm

Every JWT declares its signing algorithm in the header, and the choice between the two common ones is not a matter of taste. It determines who can mint a valid token, how you rotate keys, and whether a subtle verification bug hands an attacker the ability to forge anything.

The two families

HS256 is HMAC with SHA-256. It uses one shared secret. Whoever can verify a token can also create one, because verification means recomputing the same signature with the same key.

RS256 is RSA with SHA-256. It uses a key pair. The private key signs, the public key verifies, and holding the public key gives you no ability to forge. ES256 is the same idea using ECDSA on the P-256 curve, with much smaller signatures and faster signing.

That asymmetry is the entire decision.

Choosing

Use HS256 when one service issues and verifies its own tokens. A monolith handing out session tokens it will later check has no need for asymmetry. HMAC is faster, the tokens are smaller, and there is one secret to manage rather than a key pair and a distribution mechanism.

Use RS256 or ES256 when anything other than the issuer verifies tokens. The moment a second service needs to check a token, HS256 requires you to copy the signing secret to it, and every copy is a service that can now issue tokens impersonating any user. In a system with a dozen services, the twelfth one to be compromised can forge an administrator token.

With RS256 you distribute only the public key. A compromised verifier learns nothing useful. This is why every identity provider you might integrate with, and every third party that consumes your tokens, uses asymmetric signing.

HS256RS256 / ES256
KeysOne shared secretPrivate signs, public verifies
Verifier can forgeYesNo
Token sizeSmallestLarger (RS256), small (ES256)
Signing speedFastestSlower (RS256), fast (ES256)
Key distributionMust stay secret everywherePublic key can be published
RotationCoordinated across all holdersPublish new key in JWKS
Right forSingle serviceMultiple verifiers, third parties

If you are choosing asymmetric today, prefer ES256 over RS256 unless a consumer requires RSA. The signatures are far shorter, which matters when the token travels in a header on every request.

The algorithm confusion attack

This is the failure mode that makes the choice a security question rather than an architectural one.

A server verifies RS256 tokens using its public key. An attacker takes a legitimate token, edits the header to say "alg": "HS256", changes the payload to whatever they like, and signs the result using the server's public key as the HMAC secret.

The public key is public, so the attacker has it.

A naive verifier reads the alg field, sees HS256, fetches "the key", and computes an HMAC with the public key bytes. The signature matches. The forged token is accepted.

The bug is trusting the algorithm named in the token. The token is attacker controlled data, header included, so alg is a claim about what the attacker wants you to do, not a fact.

The related trick is "alg": "none", which some libraries historically honoured by skipping verification entirely.

The fix is to pin the expected algorithm at the verifier and ignore the header's claim. Not "verify with whatever the token says", but "verify as RS256, and reject anything else". Every mature library supports this, usually as a required parameter:

// Node, jsonwebtoken
jwt.verify(token, publicKey, { algorithms: ['RS256'] })
# PyJWT
jwt.decode(token, public_key, algorithms=["RS256"])

Pass the list explicitly, keep it to the one algorithm you actually use, and never build it from the incoming token. If your code passes a key without constraining the algorithm, treat that as a live vulnerability rather than a style issue.

Secret strength for HS256

If you use HS256, the secret has to be a real key. HMAC-SHA256 assumes roughly 256 bits of entropy, and a short or memorable secret can be brute forced offline: an attacker with one valid token can test candidate secrets locally, at speed, with no interaction with your server and nothing in your logs.

Generate 32 random bytes and encode them. Do not use a passphrase, a service name, a value that appeared in a tutorial, or anything a person composed. The token generator produces secure random values of a chosen length, and password entropy explained covers why human chosen strings fall so far short.

Key rotation and JWKS

With HS256, rotation means updating the secret everywhere that holds it, more or less simultaneously. There is no graceful overlap unless you build one by accepting two secrets during a window.

With RS256 and ES256 the standard answer is a JWKS endpoint: a JSON document listing your current public keys, each with a kid identifier. Tokens carry the matching kid in the header, verifiers fetch and cache the document, and rotation becomes publishing a new key, signing with it, and retiring the old one once outstanding tokens have expired.

Two cautions. The kid is attacker controlled like the rest of the header, so use it to select from keys you already trust, never as a path or URL to fetch. And cache the JWKS document with a sensible TTL, since fetching it per request makes your identity provider a hard dependency on every single call.

Inspecting a token

The header and payload are base64url, not encrypted, so anyone holding a token can read its contents. That is worth internalising: a JWT hides nothing, it only proves that the contents were not altered. Never put anything in a payload you would not hand to the bearer.

Paste a token into the JWT decoder to read its header and claims, including which algorithm it declares and which kid it references. The decoding runs entirely in your browser, so a production token is not transmitted anywhere.

When debugging a rejected token, check in this order: the alg matches what the verifier pins, the kid names a key the verifier knows, exp has not passed, and iss and aud match what the verifier requires. Expiry and audience mismatches account for most failures that are not key problems.

Related reading: what is inside a JWT covers the structure and registered claims, and where to store JWT tokens covers the browser side of the problem.