Most JWT tutorials show HS256 because it is simpler to implement: pick a secret string, sign and verify with the same key. For a single monolithic service that both issues and validates its own tokens, this is fine. But the moment you have more than one service validating tokens — or you issue tokens that third parties consume — HS256 becomes a security problem dressed up as convenience.
How HS256 works
HS256 is HMAC-SHA256. The server computes HMAC-SHA256(base64url(header) + "." + base64url(payload), secret) and appends the result as the signature. Verification requires running the same computation and comparing the output. Because the same key is used to both sign and verify, any service that can verify a token can also forge one.
// Node.js — HS256 sign and verify
import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET; // must be at least 32 bytes of entropy
// Signing (auth server)
const token = jwt.sign(
{ sub: 'user_123', email: 'alice@example.com' },
SECRET,
{ algorithm: 'HS256', expiresIn: '15m', issuer: 'https://auth.yourdomain.com' }
);
// Verifying (same service, or any service that has SECRET)
try {
const payload = jwt.verify(token, SECRET, {
algorithms: ['HS256'],
issuer: 'https://auth.yourdomain.com'
});
console.log(payload.sub); // 'user_123'
} catch (err) {
// TokenExpiredError, JsonWebTokenError, etc.
}
The secret-sharing problem
With HS256, every service that needs to validate a token must have a copy of the signing secret. In a microservices architecture this means the secret lives in a dozen environment variables across a dozen services, each one a potential leak surface. A data breach in your lowest-security service — say, a notification worker — compromises the signing key for your entire platform. Anyone with the HS256 secret can issue tokens claiming to be any user.
This is the fundamental asymmetry problem: validation capability and forgery capability are the same thing when using a symmetric algorithm. RS256 breaks that equation.
How RS256 works
RS256 is RSASSA-PKCS1-v1_5 with SHA-256. The server generates an RSA key pair. The private key signs tokens; the public key verifies them. Any service can safely hold the public key — knowing the public key does not enable token forgery. Only the service that holds the private key can issue tokens.
// Generate an RSA key pair (do this once, store in secrets manager)
// openssl genrsa -out private.pem 2048
// openssl rsa -in private.pem -pubout -out public.pem
import jwt from 'jsonwebtoken';
import fs from 'fs';
const PRIVATE_KEY = fs.readFileSync('./private.pem');
const PUBLIC_KEY = fs.readFileSync('./public.pem');
// Signing — only the auth service does this
const token = jwt.sign(
{ sub: 'user_123', email: 'alice@example.com' },
PRIVATE_KEY,
{
algorithm: 'RS256',
expiresIn: '15m',
issuer: 'https://auth.yourdomain.com',
keyid: 'key-2025-11' // the 'kid' header — critical for rotation
}
);
// Verifying — any service can do this with only PUBLIC_KEY
const payload = jwt.verify(token, PUBLIC_KEY, {
algorithms: ['RS256'],
issuer: 'https://auth.yourdomain.com'
});
algorithms explicitly when calling verify(). Without this constraint, some JWT libraries (including older versions of jsonwebtoken) accepted the alg: "none" header, which bypasses signature verification entirely. This is one of the most common JWT vulnerabilities in the wild.The JWKS endpoint
RS256 enables a key distribution mechanism that doesn't exist for symmetric algorithms: the JSON Web Key Set endpoint. A JWKS endpoint is a public URL — typically at /.well-known/jwks.json or /oauth2/v1/keys — that returns the set of public keys the issuer uses to sign tokens. Any service can fetch this endpoint to get the current verification keys without any secret distribution ceremony.
// Example JWKS response
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "key-2025-11",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2...",
"e": "AQAB"
},
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "key-2025-08",
"n": "pjdss8ZaDfEH6K6U7GeW2nxDqR4IP049fk1fK...",
"e": "AQAB"
}
]
}
Consumers of your tokens can verify them against this endpoint without any prior coordination. This is why third-party API consumers, webhooks, and external microservices can safely validate JWTs from identity providers like Google, GitHub, or Bastionary without any setup beyond pointing at the JWKS URL.
// Verifying RS256 tokens using JWKS (Node.js)
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://auth.yourdomain.com/.well-known/jwks.json')
);
async function verifyToken(token) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.yourdomain.com',
audience: 'https://api.yourdomain.com',
});
return payload;
}
// jose caches the JWKS response and re-fetches on unknown kid
Key rotation with the kid header
The kid (key ID) field in the JWT header solves the rotation problem. When you rotate signing keys, you publish both the old and new public keys in your JWKS endpoint simultaneously. New tokens are signed with the new private key and carry the new kid. Old tokens that haven't expired yet carry the old kid. Verifiers look up the correct key by matching kid.
This means you can rotate without invalidating sessions. The rotation procedure is:
- Generate a new RSA key pair
- Add the new public key to JWKS (both keys now present)
- Start signing new tokens with the new private key
- Wait for all tokens signed by the old key to expire (one max-lifetime window, typically 15 minutes for access tokens)
- Remove the old public key from JWKS
- Retire the old private key
If your access tokens have a 15-minute lifetime and your refresh tokens are opaque (not JWTs), the entire rotation takes 15 minutes with zero user disruption.
When HS256 is still appropriate
HS256 is not wrong in every context. It is faster to compute (about 5x faster than RS256 on commodity hardware), and it is simpler to implement correctly. For internal service-to-service tokens where a single service both issues and validates, and where the secret is properly managed in a secrets manager like Vault or AWS Secrets Manager, HS256 is a reasonable choice. The rule of thumb is: if the signing service and all validating services are under the same trust boundary and operational control, HS256 is acceptable. If any token consumer is external — even an internal service owned by a different team — use RS256.
ES256: a better default than RS256
While this post is titled RS256 vs HS256, the modern recommendation for new systems is actually ES256: ECDSA with P-256 and SHA-256. ES256 offers the same asymmetric properties as RS256 (private key signs, public key verifies, JWKS distribution works the same) but produces much shorter keys and signatures. A 256-bit EC key is roughly equivalent in security to a 3072-bit RSA key. Smaller tokens mean less bandwidth, faster parsing, and smaller JWKS payloads. The jose library used above supports ES256 identically to RS256 — just change the algorithm string and generate an EC key pair instead of RSA.
# Generate an EC P-256 key pair openssl ecparam -name prime256v1 -genkey -noout -out ec-private.pem openssl ec -in ec-private.pem -pubout -out ec-public.pem