JWT Signature Verification Explained: What Is Signed, HMAC vs RSA vs ECDSA, and What It Does Not Prove

A JWT signature proves two things: the header and payload have not changed since signing, and whoever signed them held a particular key. It proves nothing else. It does not hide the payload, it does not say the token is still valid, and it does not say the token was meant for you. Knowing what is signed, and how each algorithm checks it, tells you which of those guarantees you are actually getting.

What exactly gets signed

A signed JWT uses the JWS compact serialisation from RFC 7515: three Base64url strings separated by dots. The signature is computed over the ASCII bytes of the first two parts joined by a dot, the "signing input". It is not computed over the decoded JSON.

signing_input = base64url(header_json) + "." + base64url(payload_json)
signature     = SIGN(key, signing_input)
token         = signing_input + "." + base64url(signature)

Because the encoded strings are signed, any change invalidates the signature: a different claim value, a reordered key, an added space, even re-encoding with padding. That is why you must verify the token exactly as received, before any parsing or normalisation.

The header is signed too. The alg, kid and typ values are protected, so an attacker cannot change the header without breaking the signature. But the verifier reads alg before it has verified anything, which is the root of the alg: none and algorithm-confusion attacks covered below.

HMAC: HS256, HS384, HS512

HMAC algorithms compute a keyed hash of the signing input with a shared secret. Verification recomputes the same HMAC and compares the result. There is no separate public key: anyone who can verify can also sign.

import hmac, hashlib, base64

def b64url(b: bytes) -> str:
    return base64.urlsafe_b64encode(b).rstrip(b'=').decode()

signing_input = f'{header_b64}.{payload_b64}'.encode()
expected = b64url(hmac.new(secret, signing_input, hashlib.sha256).digest())
valid = hmac.compare_digest(expected, signature_b64)

HS256 fits a single service that both issues and checks its own tokens, such as a session token. It is a poor fit when several services verify tokens from one issuer, because each of them would hold a key that can mint tokens for all the others.

The secret must be random, not a password. RFC 7518 requires a key at least as long as the hash output, 256 bits for HS256. Tokens are often visible to clients, and every token is an offline guessing oracle: tools such as hashcat test weak HS256 secrets at very high speed.

Compare in constant time (hmac.compare_digest, crypto.timingSafeEqual). A plain == can leak through timing how many leading bytes matched.

RSA: RS256 and PS256

RSA algorithms sign with a private key and verify with the public key. Services that only need to check tokens get the public key, usually from the issuer's JWKS endpoint, and cannot create tokens themselves. This asymmetry is why identity providers default to RS256.

RS256 uses RSASSA-PKCS1-v1_5 with SHA-256. PS256 uses RSASSA-PSS, which has a randomised padding scheme and a stronger security proof. Both use the same RSA keys, but they are different algorithms: a verifier set up for RS256 rejects a PS256 token, and the other way round. Use keys of at least 2048 bits, as RFC 7518 requires.

RSA signatures are as long as the modulus, 256 bytes for a 2048-bit key, or about 342 Base64url characters. That is often most of the token's size.

Elliptic curves: ES256 and EdDSA

ES256 is ECDSA on the P-256 curve with SHA-256. It gives security similar to 3072-bit RSA with a 64-byte signature, so tokens are much smaller. EdDSA, registered for JOSE by RFC 8037 and normally used with Ed25519, is deterministic, fast and hard to implement wrongly.

ECDSA has an interoperability trap. JWS stores the signature as the two integers R and S concatenated as fixed-length big-endian values, 64 bytes for ES256. Most crypto libraries, OpenSSL included, produce and expect the ASN.1 DER encoding instead, which is variable length, usually 70 to 72 bytes. Passing one format where the other is expected makes every token fail verification. JWT libraries convert for you; hand-rolled code with a generic crypto API is where this breaks.

// Node.js: ask for the JOSE (IEEE P1363) format explicitly
const { verify } = require('node:crypto');
const ok = verify('sha256', Buffer.from(signingInput), {
  key: publicKey,
  dsaEncoding: 'ieee-p1363',       // raw R||S, as JWS uses
}, signatureBytes);

The verification decision, step by step

A correct verifier does these steps in this order and rejects the token at the first failure.

1. Split into exactly three parts; reject anything else.
2. Decode the header; read alg and kid — but trust neither yet.
3. Check alg is in YOUR allow-list (e.g. only RS256). Reject "none".
4. Choose the key from YOUR configuration or JWKS, by kid.
   The key must be of the type that alg requires.
5. Verify the signature over the original "header.payload" bytes.
6. Only now parse the payload and check exp, nbf, iss, aud.
7. Only now use sub, scope, roles.

Step 3 blocks alg: none, where an attacker sends an unsigned token and a naive library accepts it. Step 4 blocks algorithm confusion. There, an attacker switches an RS256 token to HS256 and signs it with the RSA public key as the HMAC secret. A library that lets the header choose the algorithm and accepts the public key as a byte string would verify it. RFC 8725 describes both attacks and requires each key to be used with exactly one algorithm.

What a valid signature does not tell you

It does not mean the payload is secret. JWS payloads are only Base64url-encoded, so anyone holding the token can read every claim. Do not put personal data or internal identifiers in a JWT unless you are happy for the client to see them, or use JWE (RFC 7516), which encrypts.

It does not mean the token is current. A signature stays valid forever. Expiry comes from exp, which you must check separately, and revocation needs extra machinery such as short lifetimes with refresh tokens, a deny-list of jti values, or token introspection.

It does not mean the token is for you. Every service trusting the same issuer sees the same valid signatures. The aud claim tells you which service the token was issued for, and skipping that check lets a token for one API be replayed against another.

It does not mean the key is still trusted. If a signing key leaks, every token it signed verifies until you remove that key from your trusted set. Plan for rotation with kid values and a JWKS endpoint, so a key can be retired without redeploying every verifier.

Choosing an algorithm

For a single application issuing and verifying its own tokens, HS256 with a 256-bit random secret is simple and fast. For anything where verifiers should not be able to issue tokens, such as microservices, third-party APIs or mobile clients, use an asymmetric algorithm. ES256 or EdDSA give small tokens and fast signing. RS256 is the most widely supported and is the default at most identity providers. PS256 is a good choice when all your libraries support it.

Whatever you choose, configure it explicitly on the verifier. Never let the token's own header decide.

Sources and further reading

RFC 7515 (JWS) defines the signing input and compact serialisation. RFC 7518 (JWA) defines HS, RS, PS and ES algorithms, key-size requirements and the R||S ECDSA format. RFC 8037 adds EdDSA. RFC 7519 defines JWT claims, and RFC 8725 lists JWT best current practices, including the none and algorithm-confusion attacks.

Frequently asked questions

How does JWT signature verification work?

The verifier takes the first two parts of the token exactly as received, `header.payload`, and checks the third part against them with the expected key and algorithm. For HMAC it recomputes the MAC; for RSA or ECDSA it checks the signature with the public key. Any change to the header or payload makes the check fail.

What is the difference between HS256 and RS256?

HS256 uses one shared secret for both signing and verifying, so every verifier could also create tokens. RS256 signs with a private key and verifies with a public key, so verifiers cannot mint tokens. That makes RS256 the usual choice when several services check tokens from one issuer.

Does a JWT signature encrypt the payload?

No. A signed JWT (JWS) is only Base64url-encoded and anyone can read its claims. Use JWE if the contents must be confidential, and keep sensitive data out of JWTs where you can.

Why does my ES256 signature fail to verify with OpenSSL?

JWS encodes ECDSA signatures as raw 64-byte R||S, while OpenSSL and many crypto APIs use DER encoding. Convert between the formats, or use a JWT library that does it, or set `dsaEncoding: 'ieee-p1363'` in Node.js.

Can a JWT be valid but expired?

Yes. The signature never expires; `exp` is a separate claim that the verifier must check. A correctly signed token past its `exp` must be rejected.

What does "JWT signature does not match locally computed signature" mean?

The verifier recomputed the signature and got a different value. Usually the key is wrong (another environment, a rotated secret, a secret read with different bytes or encoding), or the token was modified or re-serialised after signing.

What is the alg none attack?

An attacker sets the header to `"alg": "none"` and removes the signature. A library that trusts the header treats the token as valid without checking anything. Pin the list of accepted algorithms on the verifier to prevent it.