JWT RS256 vs HS256: Asymmetric vs Symmetric Signing — Which to Use

Last updated:

HS256 signs JWTs with a single shared secret — same byte string for signer and verifier — using HMAC-SHA256. RS256 signs with an RSA private key and verifies with the matching public key, so the verifier never holds the forging key. The wire format is identical; the trust model is not. Pick HS256 when the same service issues and verifies tokens — it is faster, simpler, and produces smaller tokens. Pick RS256 (or modern alternatives like ES256 and EdDSA) when multiple parties verify tokens without being trusted to mint them, when you need JWKS-based key rotation, or when the issuer is an external identity provider. Both algorithms collapse the same way under the alg: none attack and algorithm-confusion attacks — the fix in every case is an explicit algorithm whitelist in the verifier.

Need to inspect a JWT's header to see which algorithm it claims? Jsonic's JWT Decoder shows the alg and kid values without sending the token anywhere — all decoding happens in your browser.

Decode a JWT

HS256 vs RS256 in one paragraph (decision table)

If the same service signs and verifies, use HS256 — it is faster, simpler, and the shared-secret model adds no risk because the verifier already has full minting authority. If a third party verifies tokens you issue, or you want to rotate the signing key without touching every verifier, use RS256 (or EdDSA if your stack supports it). Token format is identical either way; only the signature bytes and the key material differ. The differences that actually matter in production are key distribution, rotation operations, signature size, and CPU cost per verify.

DimensionHS256RS256
SymmetrySymmetric (one secret)Asymmetric (private + public)
Key material32+ random bytesRSA keypair, 2048+ bits
Signature size32 bytes256 bytes (2048-bit key)
Sign speedVery fast (HMAC)Slow (modular exponentiation)
Verify speedVery fastFaster than sign, slower than HMAC
Verifier can forge?Yes — same secret signs and verifiesNo — verifier only has public key
JWKS-friendly?No (would expose secret)Yes — public key is safe to publish
Fits OIDC / third-party verifiersNoYes (RS256 is OIDC default)
Typical useMonolith, single-trust-boundary servicesIdP-issued tokens, microservices, mobile clients

For the surrounding mechanics — what each JWT segment contains, how the signature is computed, and what claims to set — see How JWT works and How to decode a JWT.

HS256 (HMAC-SHA256): symmetric, shared secret

HS256 stands for HMAC with SHA-256. The signer concatenates base64url(header) + '.' + base64url(payload), runs the result through HMAC-SHA256 keyed by a shared secret, and appends the base64url-encoded 32-byte MAC as the signature segment. The verifier repeats the operation with the same secret and compares the result in constant time. There is no asymmetry — the same secret that signs also verifies.

Strengths: blazing fast (one keyed hash pass), trivial setup (one environment variable), small tokens (32-byte signature vs 256 bytes for RS256). Weaknesses: every verifier must hold the signing key, so any compromised verifier becomes a token factory. There is no way to publish "verify-only" material — JWKS does not work for HS256.

// Node.js — jsonwebtoken (HS256 sign + verify)
import jwt from 'jsonwebtoken'
const secret = process.env.JWT_SECRET // 32+ random bytes, base64-encoded
// Sign
const token = jwt.sign(
  { sub: 'user_42', role: 'admin' },
  secret,
  { algorithm: 'HS256', expiresIn: '15m', issuer: 'jsonic.io' }
)
// Verify — ALWAYS pass an explicit algorithm whitelist
const payload = jwt.verify(token, secret, {
  algorithms: ['HS256'],   // reject anything else, especially 'none'
  issuer: 'jsonic.io',
})

The algorithm whitelist on verify is non-negotiable. Without it, an attacker can swap alg to none and submit an unsigned token, or — in setups that also accept RS256 — submit an HS256 token signed with the RS256 public key as the HMAC secret. Both attacks fail when the verifier hardcodes the allowed algorithms.

RS256 (RSA-SHA256): asymmetric, public/private keypair

RS256 stands for RSASSA-PKCS1-v1_5 with SHA-256 (RFC 7518 §3.3). The signer hashes the header-and-payload string with SHA-256, then signs the hash with an RSA private key using PKCS#1 v1.5 padding. The verifier hashes the same input and uses the corresponding RSA public key to confirm the signature. The private key signs; the public key only verifies — it cannot forge.

That asymmetry is what makes RS256 the default for OpenID Connect and federated identity in general. An identity provider (Auth0, Clerk, Cognito, Okta) holds the private key, publishes the public key at a JWKS endpoint, and any number of relying parties can verify tokens without trusting one another. Compromising a verifier reveals only verification capability — not the ability to mint new tokens.

// Node.js — jsonwebtoken (RS256 sign + verify with PEM files)
import fs from 'node:fs'
import jwt from 'jsonwebtoken'
const privateKey = fs.readFileSync('./private.pem', 'utf8')
const publicKey  = fs.readFileSync('./public.pem', 'utf8')
const token = jwt.sign(
  { sub: 'user_42', role: 'admin' },
  privateKey,
  {
    algorithm: 'RS256',
    expiresIn: '15m',
    issuer: 'jsonic.io',
    keyid: '2026-q2',   // sets the kid header for rotation
  }
)
const payload = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],   // critical — prevents algorithm confusion
  issuer: 'jsonic.io',
})

In production, verifiers usually do not hold a static public.pem— they fetch the public key from the issuer's JWKS endpoint, keyed by the kid from the token header. Libraries like jwks-rsa wrap the fetch, cache, and key-lookup logic.

# Python — PyJWT (RS256 verify against a remote JWKS)
import jwt
from jwt import PyJWKClient
jwks_client = PyJWKClient('https://jsonic.io/.well-known/jwks.json')
signing_key = jwks_client.get_signing_key_from_jwt(token).key
payload = jwt.decode(
    token,
    signing_key,
    algorithms=['RS256'],   # explicit whitelist
    issuer='jsonic.io',
    audience='api.jsonic.io',
)

ES256, EdDSA, PS256 — modern asymmetric alternatives

RS256 is the safest default for interoperability, but several newer algorithms beat it on size, speed, and side-channel resistance. All three are asymmetric — same trust model as RS256 — and all three swap into the same JWT pipeline with a different alg header value.

  • ES256 (ECDSA with P-256 + SHA-256) — elliptic-curve signatures using the NIST P-256 curve. Keys and signatures are 64 bytes (vs 256 bytes for RS256), signing is faster, and the algorithm is supported in nearly every modern JWT library. ECDSA requires a fresh random nonce per signature; broken RNGs have leaked private keys in the past (Sony PS3, 2010), but modern libraries use deterministic ECDSA (RFC 6979) and the failure mode is well-understood.
  • EdDSA (Ed25519) — Edwards-curve signatures specified in RFC 8037 for JOSE. Keys are 32 bytes, signatures are 64 bytes, and signing is faster than ECDSA. EdDSA is deterministic by construction (no per-signature randomness needed) and has stronger side-channel resistance than RSA or ECDSA. This is the modern recommendation when both signer and verifier support it.
  • PS256 (RSASSA-PSS with SHA-256) — same RSA key material as RS256 but with PSS padding instead of PKCS#1 v1.5. PSS has a formal security proof that PKCS#1 v1.5 lacks. Use PS256 when you already have RSA keys and want the stronger padding scheme; use EdDSA if you can pick the algorithm from scratch.
// jose library — EdDSA (Ed25519) sign + verify
import { SignJWT, jwtVerify, generateKeyPair } from 'jose'
const { publicKey, privateKey } = await generateKeyPair('EdDSA', {
  crv: 'Ed25519',
  extractable: true,
})
const token = await new SignJWT({ sub: 'user_42', role: 'admin' })
  .setProtectedHeader({ alg: 'EdDSA', kid: '2026-q2' })
  .setIssuer('jsonic.io')
  .setExpirationTime('15m')
  .sign(privateKey)
const { payload } = await jwtVerify(token, publicKey, {
  algorithms: ['EdDSA'],   // explicit whitelist as always
  issuer: 'jsonic.io',
})

The alg header values are spec-defined: HS256, RS256, ES256, EdDSA, PS256 — see JWS specification for the full list and JWK structure for how each key is represented inside a JWKS document.

Key generation: HS256 random secret vs RSA 2048+ keypair

HS256 needs one piece of random material; RS256 needs a keypair. The commands are short either way, but the output and handling are very different.

HS256 — generate a 256-bit random secret:

# 32 random bytes, base64-encoded (URL-safe, no padding)
openssl rand -base64 32
# Equivalent in Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# Equivalent in Python
python -c "import secrets; print(secrets.token_urlsafe(32))"
# Store the output in your secret manager / .env file:
#   JWT_SECRET=H7s5tQp...

RS256 — generate a 2048-bit RSA keypair:

# Generate the private key (2048 bits min; 3072 or 4096 for longer-lived keys)
openssl genrsa -out private.pem 2048
# Derive the public key from the private key
openssl rsa -in private.pem -pubout -out public.pem
# Optional — convert the public key to JWK format for JWKS publishing
# (use a library like 'pem-jwk' or 'node-jose' to produce the JSON structure)
# Lock the private key down
chmod 600 private.pem

Key-handling rules that apply to every algorithm:

  • Never commit private keys or HMAC secrets to a repository. Use environment variables or a secret manager (Vercel env vars, AWS Secrets Manager, HashiCorp Vault).
  • Rotate keys on a fixed schedule — quarterly is a common cadence for production tokens — and immediately on suspected compromise.
  • Keep at least one previous key around during rotation so outstanding tokens remain verifiable. Drop it only after the longest-lived token TTL has elapsed.
  • For RS256, 2048 bits is the practical floor; NIST guidance treats 2048-bit RSA as secure through 2030. Use 3072+ for keys that must outlive that date.

For other JWT operational concerns — expiration, audience, refresh-token strategy — see JWT best practices.

JWKS endpoint: serving public keys to verifiers

A JWKS (JSON Web Key Set) endpoint is the standard way for an asymmetric token issuer to publish its verification keys. The convention is to host the document at https://issuer.example.com/.well-known/jwks.json and return a JSON object with a keys array. Each entry is a JWK (JSON Web Key) describing one public key — its algorithm, key ID, intended use, and the key material itself.

{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "2026-q2",
      "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx...",
      "e": "AQAB"
    },
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "2026-q1",
      "n": "wYn1S7QBjzVqoeFR68iqyB8oVRpEpcZdz3PIqYf3WPRtbF8m7Hwq...",
      "e": "AQAB"
    }
  ]
}

How verifiers use the JWKS: the verifier reads the kid from the token header, fetches the JWKS (or pulls from cache), finds the entry whose kid matches, reconstructs the public key from n and e, and verifies the signature. If no entry matches, the verifier refetches the JWKS (in case it is stale), then rejects the token if still no match.

Caching: JWKS endpoints typically ship a Cache-Control: max-age=3600 header so verifiers cache for an hour. Shorter TTLs propagate rotations faster but increase load on the issuer; longer TTLs do the reverse. An hour is a sane default. Verifiers should always re-fetch on an unknown kid regardless of cache age — otherwise rotation cannot work within a single TTL window.

JWKS does not work for HS256. Publishing an HMAC secret in a JWKS-style document would hand attackers the ability to mint tokens. Symmetric keys must be distributed out-of-band through a secret manager. This is one of the main reasons multi-party token flows use RS256 or EdDSA.

Key rotation strategies (kid claim + JWKS rotation)

Key rotation is the most error-prone JWT operation. The pattern that works in every environment is the same: identify keys by a kid header claim, run two keys in parallel during the cutover, then retire the old one once outstanding tokens have expired.

Step-by-step RS256 rotation:

  1. Generate the new keypair. Run openssl genrsa -out private-new.pem 2048 and extract the public key. Assign a new kid — a date string (2026-q2) or a content hash both work.
  2. Publish the new public key in JWKS first. Add it to the keys array next to the existing one. Verifiers fetch the updated JWKS and now hold both public keys. Do not switch the signer yet.
  3. Wait one JWKS TTL. If the cache header is max-age=3600, wait at least an hour to ensure every verifier has the new key.
  4. Cut the signer over to the new private key. Update the signing service to use private-new.pem with kid: 2026-q2. New tokens carry the new kid; verifiers look it up in the JWKS and use the matching public key.
  5. Wait the maximum token lifetime. If tokens live 24 hours, wait 24 hours after the cutover. All outstanding old tokens have now expired.
  6. Remove the old public key from the JWKS. The old kid is no longer present and will never appear in a valid token again.

HS256 rotation works similarly with two secrets. Issue new tokens under kid: 2026-q2 signed with SECRET_NEW. The verifier checks the kid and uses SECRET_OLD or SECRET_NEW accordingly. Distribute both secrets to every verifier through your secret manager during the rotation window, then drop the old one when outstanding tokens have expired. Without a kid claim the verifier has no way to know which secret to try first, and you will either reject valid tokens or spend cycles trying every key.

Emergency rotation (suspected key compromise) compresses the same steps: publish the new key, cut the signer immediately, and accept the brief window during which still-cached JWKS docs cause verification failures. The alternative — continuing to honor a compromised key — is worse.

The 'alg: none' attack and other signing pitfalls

The JWT spec includes an alg value of nonemeaning "no signature" — the third segment after the second dot is empty. The attack is one line: take a legitimate token, decode the header, swap "alg": "RS256" for "alg": "none", strip the signature, and submit the modified token. If the verifier reads alg from the header and follows it, no signature check happens and the forged token passes.

This was tracked as CVE-2015-9235 against the original node-jsonwebtoken library and has cycled through dozens of libraries since. Modern defaults reject alg: none, but defaults are no defense — a new library version, a different language, or a custom verifier can reintroduce the hole.

The fix: never let the verifier infer the algorithm from the token header. Pass an explicit whitelist:

// jsonwebtoken (Node)
jwt.verify(token, key, { algorithms: ['RS256'] })   // not [], not omitted
// PyJWT (Python)
jwt.decode(token, key, algorithms=['RS256'])
// jose (JS/TS)
await jwtVerify(token, key, { algorithms: ['RS256'] })
// Go jwt-go
jwt.Parse(tokenString, keyFunc, jwt.WithValidMethods([]string{"RS256"}))

Algorithm confusion attack: a related class of bug. An attacker takes an RS256 token, changes the header to HS256, and re-signs using the RSA public key as the HMAC secret. If the verifier accepts both RS256 and HS256 and looks up the key by some other mechanism (not by the algorithm), it will happily verify the forged token because it now treats the public key as a symmetric secret. Same fix: a single-algorithm whitelist, and never let the same endpoint accept both symmetric and asymmetric algorithms.

Other signing pitfalls worth knowing:

  • Weak HS256 secrets. Anything under 256 bits of entropy is brute-forceable. Hashcat has a JWT-cracking mode. Use 32 random bytes, not a memorable string.
  • Missing iss and aud checks. A valid signature only proves the issuer holds the key. If you do not verify the issuer and audience claims, a token from one of your services could be replayed against another. See JWT best practices for the full claim-validation checklist.
  • Skipped exp checks. Some libraries verify the signature but not the expiration unless asked. Always verify exp (expiration) and nbf (not-before) explicitly.
  • Public keys served over HTTP. JWKS endpoints must be HTTPS. An attacker who can MITM the JWKS fetch can publish their own public key and any token they sign with the matching private key will verify.
  • Long-lived tokens with no rotation. A JWT that lives a week with no key rotation gives an attacker a week to crack it. Pair short token lifetimes with refresh tokens — see JWT vs session for the trade-offs.

Key terms

HS256
HMAC with SHA-256. Symmetric JWT signing algorithm using a single shared secret of at least 256 bits. Same key signs and verifies; cannot use JWKS for distribution.
RS256
RSASSA-PKCS1-v1_5 with SHA-256. Asymmetric algorithm using an RSA keypair (2048-bit minimum). Private key signs; public key verifies. OIDC default.
ES256 / EdDSA / PS256
Modern asymmetric alternatives to RS256. ES256 uses ECDSA P-256 (smaller keys). EdDSA uses Ed25519 (smallest, fastest, modern recommendation). PS256 uses RSA with PSS padding (stronger than PKCS#1 v1.5).
JWKS
JSON Web Key Set. A JSON document containing the public keys a verifier needs. Conventionally served at /.well-known/jwks.json over HTTPS with a Cache-Control TTL of around one hour.
kid claim
Key ID header field that identifies which key signed the token. Required for clean rotation — without it, verifiers cannot tell which key in the JWKS (or which HMAC secret) to use.
alg: none attack
CVE-2015-9235 class vulnerability. An attacker swaps the alg header to none and strips the signature. Defeated by passing an explicit algorithm whitelist to every verifier.
algorithm confusion attack
An attacker re-signs an RS256 token as HS256 using the RSA public key as the HMAC secret. Defeated by a single-algorithm whitelist and never mixing symmetric and asymmetric algorithms on the same verifier.

Frequently asked questions

What's the difference between HS256 and RS256?

HS256 is symmetric: a single shared secret signs and verifies the JWT. Both signer and verifier hold the same byte string, and anyone with that secret can mint valid tokens. RS256 is asymmetric: an RSA private key signs and a separate RSA public key verifies. The verifier never holds the signing key, so a compromised verifier cannot forge tokens. The wire format is identical — both produce a base64url signature appended to the header.payload — but the trust model is fundamentally different. HS256 fits a single trust boundary (one issuer, one verifier, both your service). RS256 fits multi-party setups: identity provider issues tokens, many independent services verify them using a published public key. Performance differs too: HS256 is roughly 10–100x faster to sign and verify than RS256 because HMAC-SHA256 is a single hash pass, while RSA requires modular exponentiation on a 2048+ bit modulus.

Is RS256 more secure than HS256?

Not inherently — both are cryptographically sound when used correctly. What RS256 buys you is a smaller blast radius on key compromise: a leaked verifier public key reveals nothing, while a leaked HS256 secret lets an attacker mint arbitrary tokens. If every party that verifies tokens must also be able to sign them, that distinction is moot and HS256 is fine. RS256 also enables key separation: rotate the signing key without redeploying every verifier (they fetch the new public key from a JWKS endpoint). The security failure modes are different too — HS256 is vulnerable to weak secrets (anything under 256 bits of entropy is brute-forceable), while RS256 is vulnerable to algorithm confusion attacks (an attacker swaps alg to HS256 and signs with the public key as the HMAC secret). Both algorithms are ruined by the alg:none attack if the verifier accepts it. Pick based on trust topology, not a vague sense that one is "stronger".

When should I use HS256 instead of RS256?

Use HS256 when the same service that issues tokens also verifies them. The canonical case is a monolithic app: you sign on login, you verify on every subsequent request, and there is no third party in the picture. HS256 is simpler to set up (one secret instead of a keypair), faster (HMAC outperforms RSA by an order of magnitude), and produces smaller tokens (signatures are 32 bytes vs 256 bytes for RS256). It also fits short-lived service-to-service tokens where two services already share infrastructure (same Vercel project, same Kubernetes namespace, shared secret store). Switch to RS256 the moment a third party needs to verify tokens without being trusted to issue them — third-party APIs consuming your tokens, microservices owned by different teams, mobile clients that should not hold a forging key, or any setup where you want JWKS-based rotation.

How long should an HS256 secret be?

At minimum 256 bits (32 bytes) of cryptographic random data. RFC 7518 §3.2 requires the key length to match the hash output for HMAC, which is 256 bits for SHA-256. Shorter keys are technically allowed by some libraries but reduce the effective security level — a 128-bit HMAC-SHA256 key is no stronger than HMAC-SHA128. Use a CSPRNG (crypto/rand in Go, secrets.token_bytes(32) in Python, crypto.randomBytes(32) in Node) — never a password, never a UUID (only 122 bits of entropy), never a derived hash of something memorable. Encode the output as base64 or hex for storage in environment variables. If you need to derive a key from a passphrase, run it through PBKDF2 or Argon2 with high iteration counts first; do not feed a raw passphrase to HMAC. Rotate the secret periodically and keep at least one previous secret around so existing tokens remain verifiable during the cutover.

What is a JWKS endpoint?

A JWKS (JSON Web Key Set) endpoint is a URL that publishes the public keys a verifier needs to validate JWTs issued by a given issuer. The convention is to host it at https://issuer.example.com/.well-known/jwks.json and serve a JSON document containing a keys array. Each entry is a JWK with a kid (key ID) that matches the kid header in the token. The verifier reads the kid from the token header, looks up the matching key in the JWKS, and uses it to verify the signature. JWKS makes key rotation operationally clean: the issuer publishes a new key with a new kid, signs new tokens with it, and removes the old key from the JWKS once outstanding tokens expire. Verifiers fetch and cache the JWKS (typically for an hour) and re-fetch when they see an unknown kid. OIDC providers like Auth0, Clerk, and Cognito all expose JWKS endpoints; if you issue tokens for third parties, you should too.

How do I rotate JWT signing keys without invalidating tokens?

Use a kid (key ID) header claim to identify which key signed each token, then run two keys in parallel during the rotation window. Step 1: generate the new keypair and add the public key to your JWKS with a new kid (e.g., kid: 2026-q2). Step 2: keep signing with the old key — verifiers fetch the new JWKS and now hold both public keys. Step 3: after caches refresh (an hour or two for a 1-hour TTL), switch the signer to the new private key. Tokens issued before the cutover still verify against the old public key; new tokens verify against the new one. Step 4: once the longest-lived outstanding token has expired (e.g., 24 hours after the cutover for a 24-hour token TTL), remove the old public key from the JWKS. For HS256, the same pattern works with two secrets: track them by kid, accept either during the rotation window, then drop the old one. Never rotate without a kid — verifiers cannot tell which key to use and you will reject valid tokens.

What is the 'alg: none' attack?

The JWT spec defines an alg value of "none" meaning the token has no signature — the third segment after the second dot is empty. The attack is straightforward: an attacker takes a legitimate token, decodes the header, swaps alg from RS256 (or HS256) to none, strips the signature segment, and submits the modified token. If the verifier blindly trusts the alg header and follows it, no signature check happens and the forged token passes. This was tracked as CVE-2015-9235 against the original node-jsonwebtoken vulnerability and has since affected many libraries. The fix is universal: never read the algorithm from the token header. Pass an explicit algorithm whitelist to your verifier (algorithms: ["RS256"] in jsonwebtoken, algorithms=["RS256"] in PyJWT, alg in jose) and reject any token whose header alg is outside the whitelist. Modern library defaults now reject alg:none, but algorithm confusion attacks (RS256 token verified as HS256 using the public key as the HMAC secret) keep finding new victims.

Should I use RS256 or EdDSA (Ed25519)?

EdDSA with Ed25519 is the modern recommendation when both signer and verifier can support it. Compared with RS256: keys are 32 bytes (vs 256+ bytes for RSA-2048), signatures are 64 bytes (vs 256 bytes for RS256), signing and verifying are faster, and the algorithm has stronger resistance to side-channel attacks because there is no secret-dependent branching. The only downside is ecosystem support — older identity providers and some enterprise verifiers only ship RSA. RS256 remains the safest default for maximum interoperability (it is mandatory-to-implement for OIDC). Pick EdDSA when you control both ends (internal microservices, mobile apps you ship, infrastructure you own) and want smaller tokens with better performance. Pick RS256 when consumers may use older libraries or follow OIDC conventions strictly. ES256 (ECDSA P-256) is a middle ground — better than RS256 on key/signature size, but Ed25519 is generally a stronger choice now that library support has caught up.

Further reading and primary sources