Understanding JWT signing
A signature, not a secret.
What it means to sign a JWT, what the signature actually proves, and the embarrassingly long list of attacks that have hit JWT libraries over the years.
What signing produces.
A JWT signature is the HMAC (for symmetric keys) or asymmetric signature (for RSA, ECDSA or EdDSA keys) over the concatenation of the encoded header and the encoded payload, joined by a dot. The signature is then Base64url-encoded and appended as the third section. The token's structure is therefore header.payload.signature — three Base64url segments joined by dots.
signature = HMAC(secret, "header.payload")
What the signature proves.
Anyone holding the key can recompute the signature; anyone receiving a token can verify it. If the verification succeeds, the receiver knows two things: the header and payload weren't modified after signing, and whoever signed them knew the key. That's it. The signature does not encrypt the payload — every JWT body is base64-decoded plaintext, visible to anyone who has the token. It does not prove anything about how the token was obtained or whether it should still be honoured.
HS256 vs RS256 — when each fits.
HS256 uses a single shared secret. Whoever can sign can also verify. It's the right choice when the same service issues and consumes the tokens — a session token your own backend creates and reads back. RS256 uses an asymmetric key pair: the issuer signs with the private key, every other service in the network verifies with the public key. It's the right choice for tokens that cross trust boundaries — an identity provider issuing tokens consumed by half a dozen services that don't share secrets. Same security goal, different operational shape.
The "alg: none" attack.
The JWT spec defines an "alg: none" header that means "this token is not signed". It exists for cases where signing is happening outside JWT itself. Several historical JWT libraries respected this header on verification — receive a token claiming "alg: none", trust it as authentic. The fix is implementation discipline: verifiers must check that the algorithm matches the one they expected before running any verification logic. Modern libraries no longer parse "alg: none" by default, but if you implement a verifier from scratch, this is the first attack to defend against.
The algorithm-confusion attack.
When a service is configured to verify RS256 tokens with a public key, an attacker can forge a token claiming "alg: HS256" and use the public key as if it were the HMAC secret. Buggy libraries that pick the algorithm from the token header would then verify the forgery successfully. The fix is the same as the "alg: none" defence: pin the expected algorithm at the verifier and refuse any other.
A worked sign.
A header {"alg":"HS256","typ":"JWT"} Base64url-encodes to eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. A payload {"sub":"abdul","exp":1747300000} encodes to a similar string. Join them with a dot, compute HMAC-SHA256 over that joined string using the secret, and Base64url-encode the resulting 32-byte digest. Append, prefixed by another dot. The token is ready to send.
HS256 token build
signature = HMAC-SHA256(secret, header + '.' + payload)
Encode each section first, concatenate, sign the concatenation.
b64url(header) . b64url(payload) . b64url(hmac(secret, joined))
= eyJ...eyJ...sig
Claims that matter.
The signature protects the payload's integrity but not its content. A signed but long-lived token is still a problem if it leaks. Set exp (expiration) to a short window — minutes for access tokens, hours for refresh tokens, never years. Use nbf (not-before) if the token shouldn't be valid yet. Use jti for unique IDs if you ever need to revoke a specific issued token. Set aud (audience) so the token can't be replayed at a service it wasn't intended for. The verifier must check every one of those that's present.
What to put in, what to leave out.
Anything you can comfortably show to the user belongs in a JWT payload — user ID, role, tenant, scopes. Anything sensitive does not. Passwords, private keys, internal IDs you wouldn't email someone, anything that becomes a problem if logged — none of these belong in a JWT, because the JWT is plaintext to anyone holding it. If you genuinely need confidentiality of the payload, use JWE (JSON Web Encryption) on top of JWS, or encrypt the payload yourself before signing.
Secrets and key hygiene.
HS256 secrets need to be long and random — at least 256 bits of entropy, generated from a CSPRNG, never a guessable phrase. The signing key is the only thing standing between an attacker and arbitrary tokens. RS256 keys should be 2048 bits or more. Rotate keys on a schedule. Include a kid header so the verifier can pick the right key during overlap windows. And never ship a JWT signing secret to the browser — only servers should hold signing keys; clients should only ever receive tokens, never produce them.