Ultimate Guide · 2026
JWT Signature Verification: HS256 vs RS256, Explained with Code
Anyone can Base64-decode a JWT — the header and payload are not encrypted. What makes a token trustworthy is the signature: a cryptographic check proving the token was issued by someone holding the key and was not tampered with. This guide covers the three algorithm families, what each one proves, and how to verify with standard-library code.
Written by Benjamin Rotshtein
Updated
How does JWT signature verification work?
Verification recomputes the signature over the header and payload with the same secret or public key and compares it to the third segment. HS256 uses a shared secret and SHA-256; RS256 uses an RSA private key to sign and a public key to verify. If the recomputed value matches, the token was not tampered with since signing.
What the signature actually protects
A JWT has three dot-separated parts: header.payload.signature. The signature is computed over the first two parts only, so it cannot be forged without the key and breaks the moment either segment changes. Decoding the payload requires no key at all — that is why you should never place secrets in a JWT payload. Try it: paste any token into the JWT decoder and you will see the readable claims immediately.
The three algorithm families
| Algorithm | Family | Key model | Verifier | Used for |
|---|---|---|---|---|
| HS256 / HS384 / HS512 | HMAC + SHA | One shared secret (symmetric) | Anyone holding the secret can both sign and verify | Single service, trusted clients, internal APIs |
| RS256 / RS384 / RS512 | RSA | Private key signs, public key verifies (asymmetric) | Anyone with the public key can verify, but cannot sign | Issuer/audience split — e.g. auth server vs API servers |
| ES256 / ES384 / ES512 | ECDSA (P-256 etc.) | Private key signs, public key verifies (asymmetric) | Same trust model as RSA with much smaller keys | Memory-constrained clients, hardware tokens, modern defaults |
The three verification steps
- 1
Take the token's first two segments
A JWT is header.payload.signature. Verification re-derives the signature from exactly the same two segments: the Base64URL-encoded header and payload, joined with a dot.
- 2
Recompute the digest with the key
For HS256, the server computes HMAC-SHA256(header.payload, secret). For RS256/ES256 it does not recompute — it uses the public key to check that the signature was produced by the matching private key.
- 3
Reject the token if the signature does not match
If the bytes do not match, the token has been tampered with — changed payload, wrong secret, replayed token — and it must be rejected before any claim is trusted.
HS256 verification, line by line (Node.js)
const crypto = require("crypto");
function verifyHs256(token, secret) {
const [header, payload, signature] = token.split(".");
if (!header || !payload || !signature) return false;
// Recompute the HMAC over the exact signed input
const expected = crypto
.createHmac("sha256", secret)
.update(header + "." + payload)
.digest("base64url");
// Constant-time comparison defeats timing attacks
const received = signature;
const a = Buffer.from(expected);
const b = Buffer.from(received);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}The same idea in Python
import base64
import hashlib
import hmac
def verify_hs256(token: str, secret: str) -> bool:
header, payload, signature = token.split(".")
signing_input = f"{header}.{payload}".encode()
digest = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
expected = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
return hmac.compare_digest(expected, signature)What about RS256 and ES256?
RSA and ECDSA verification never recompute the signature. Instead the verifier uses the public key to check the mathematical property that the signature could only have been produced by the matching private key. That is why RS256 is the standard choice for identity providers (Auth0, Okta, Keycloak): the signing key stays on the issuer, while every API server only needs the public keys, typically fetched from a JWKS endpoint. ES256 uses the same asymmetric model with P-256 keys — faster and smaller, which is why it is common in mobile and hardware tokens.
The attacks verification prevents
- Tampering — an attacker edits the payload (for example raising an “admin” claim); the signature no longer matches and the token is rejected.
- alg: none — if a library trusts the header's algorithm, an attacker can strip the signature entirely. Whitelisting algorithms closes this.
- Algorithm confusion — tricking an HS256 verifier into using the RS256 public key as the HMAC secret. Libraries that always require an explicit, configured algorithm prevent this.
- Expired or early tokens — verification must also enforce
exp,nbfandaud. See our JWT expiration guide for the claim-level rules.
Frequently asked questions
What does a JWT signature actually prove?
It proves the header and payload have not been modified since the token was signed by a party holding the key. With HS256 that key is a shared secret. With RS256/ES256 it is the private key corresponding to the public key used for verification. The signature does not encrypt anything — the payload remains readable by anyone.
HS256 or RS256 — which should I use?
Use HS256 when the issuer and verifier are the same service (or fully trust each other) — it is simpler and faster. Use RS256 (or ES256) whenever more than one service verifies the token, because the signing key never needs to be shared: verifiers get only the public key. RS256 also protects you if a verifier leaks a key.
Can I verify a JWT signature without the secret?
No. HS256 verification requires the shared secret itself. RS256/ES256 verification requires the public key — which is public, so anyone can verify, but nobody can sign. This asymmetry is exactly why RS256 is preferred when an issuer must stay a single point of trust.
How do you verify a JWT in Node.js, Python or Go?
The trusted pattern is to use the language's standard library. In Node: crypto.createHmac('sha256', secret).update(token.split('.')[0] + '.' + token.split('.')[1]).digest() compared to the Base64URL-decoded signature. In Python: hmac.new(secret, header_payload, hashlib.sha256).digest(). In Go: crypto/hmac and crypto/sha256. Production code should use an audited library such as jsonwebtoken, PyJWT or golang-jwt that also enforces the algorithm, exp and nbf claims.
Why do libraries force an explicit algorithm whitelist?
Older libraries guessed the algorithm from the token header, which allowed the 'alg:none' attack and algorithm-confusion attacks (HS256 tokens verified as RS256 using the public key as the HMAC secret). Whitelisting alg values closes both holes. Never accept 'none', and always check exp.
See the claims your tokens expose — 100% in your browser
Decode any JWT locally and inspect the exact header and payload before you decide what to ship. Nothing leaves your machine.
Related guides
- JWT Security: Common Vulnerabilities and How to Fix Them
The attacks that break real-world JWTs and the hardening steps that stop them.
- JWT Crypto Internals: HS256, RS256 and ES256 Under the Hood
How each signing algorithm works and why alg-confusion attacks happen.
- How to Decode a JWT Locally: Header, Payload and Signature
Reading the three segments of a token by hand, without sending it anywhere.