JSON Web Tokens with HMAC Signing, Explained

After reading this you will be able to build a JSON Web Token by hand, sign it with an HMAC secret (HS256/384/512), verify one against a secret, and spot the mistakes that turn a signed token into an authentication bypass.

What a JWT is, with one hook

A JSON Web Token (JWT) is three Base64url strings joined by dots: header.payload.signature. The header and payload are JSON objects. The signature is a keyed hash over the first two parts. Here is a complete HS256 token signed with the secret your-256-bit-secret:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Decode the first part and you get {"alg":"HS256","typ":"JWT"}. The second decodes to {"sub":"1234567890","name":"John Doe","iat":1516239022}. Anyone can read both. The third part is the proof that whoever built the token knew the secret. That is the whole design: readable claims, plus a signature that binds them.

The relevant standards are RFC 7519 (JWT), RFC 7515 (JSON Web Signature), and RFC 7518 (the algorithms, including HS256). The token above is the canonical example from RFC 7515 Appendix A.1, so you can reproduce it exactly.

When to use it, and when not

Use a signed JWT when a server needs to hand a client a set of claims that the client will present back later, and the server must be able to trust those claims without a database lookup. Session-free API authentication is the common case. The server signs {"sub":"42","role":"admin","exp":...}, the client stores it, and every request carries it in an Authorization: Bearer header.

Do not use a JWT to hide anything. The payload is encoded, not encrypted. Anyone holding the token reads every claim. If you need confidentiality, you need JWE (encrypted tokens) or you keep the secret data server-side and put only an opaque identifier in the token.

HMAC is symmetric. The same secret signs and verifies. Every party that can verify a token can also forge one. If you have many independent services that only need to check tokens, prefer an asymmetric algorithm (RS256, ES256) so verifiers hold only a public key. This tool covers the HMAC family, which fits a single trusted issuer.

The signing formula and the intuition

Signing an HS256 token is one HMAC-SHA-256 over the ASCII of the joined header and payload:

\text{sig} = \text{HMAC-SHA-256}(K,\ \text{b64url}(\text{header}) \,\Vert\, \text{"."} \,\Vert\, \text{b64url}(\text{payload}))

Here K is your secret key as raw bytes, b64url is Base64url encoding with the padding = stripped, and \Vert means byte concatenation. The signing input is the exact string header.payload, then that byte string gets Base64url-encoded to become the third segment.

HMAC itself is defined in RFC 2104 as a nested hash:

\text{HMAC}(K, m) = H\big((K' \oplus opad) \,\Vert\, H((K' \oplus ipad) \,\Vert\, m)\big)

where H is the hash (SHA-256, SHA-384, or SHA-512), K' is the key padded to the hash's block size, ipad is the byte 0x36 repeated, and opad is 0x5c repeated. The two-layer construction is what stops the length-extension attack that a plain H(K \Vert m) would suffer. You do not compute this by hand; you rely on it giving a fixed-length tag: 32 bytes for HS256, 48 for HS384, 64 for HS512.

Intuition: the signature is a fingerprint of the claims that only the secret holder can produce. Change one character of the payload and the recomputed HMAC no longer matches, so verification fails.

Reproducing the RFC example

Build the demo token step by step with header {"alg":"HS256","typ":"JWT"}, payload {"sub":"1234567890","name":"John Doe","iat":1516239022}, and secret your-256-bit-secret.

  1. Encode the header JSON as UTF-8, then Base64url. You get eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
  2. Encode the payload the same way: eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
  3. Join them with a dot to form the signing input, a 111-character ASCII string ending in ...OjE1MTYyMzkwMjJ9.
  4. Compute HMAC-SHA-256 over that string with the secret bytes. The 32-byte tag Base64url-encodes to SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c.
  5. Join all three with dots. That is the token shown at the top of this page.

Now flip one bit. Change the payload name to Jane Doe and re-sign: the signature becomes gLm... entirely, because HMAC diffuses every input change across all 32 output bytes. There is no partial match.

Verifying a token and reading the result

Verification does not decrypt anything. It recomputes the signature and compares. The steps are:

  1. Split the token on dots into three parts. Reject anything that is not exactly three parts.
  2. Read the header. Confirm alg is the algorithm you expect. Do not let the token choose.
  3. Recompute the HMAC over header.payload with your secret.
  4. Compare your tag to the token's third segment using a constant-time comparison, so timing does not leak how many bytes matched.
  5. If the tags match, parse the claims and check exp, nbf, and iss against your policy.

A valid signature only tells you the claims are authentic and unmodified. It says nothing about whether the token is still current. That is what the exp claim is for. It is a Unix timestamp in seconds. If exp is 1516239022 and the current time is later, the token is expired even though the signature is perfect.

Standard registered claims from RFC 7519: iss (issuer), sub (subject), aud (audience), exp (expiry), nbf (not before), iat (issued at), jti (token ID). All are optional, but exp should be treated as mandatory in practice.

How secret length and algorithm change the tag

The three HMAC variants differ only in the underlying hash and therefore the tag length. RFC 7518 requires the secret to be at least as long as the hash output: 256 bits (32 bytes) for HS256, 384 bits for HS384, 512 bits for HS512. A short secret weakens the token no matter which algorithm you name.

HMAC variants and their fixed tag sizes
AlgorithmHashTag bytesBase64url charsMin secret bytes
HS256SHA-256324332
HS384SHA-384486448
HS512SHA-512648664

The signature segment of an HS256 token is always 43 characters (32 bytes needs 43 Base64url characters with padding stripped). If you see a signature of a different length on a token claiming HS256, something is wrong.

HMAC diffusion means a one-character change in the payload rewrites the entire signature. The RFC token signs to SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c; changing "John" to "Joan" produces a completely different 43-character signature with no shared prefix.

Common mistakes that break security

The failures below are all authentication bypasses, not cosmetic bugs.

Accepting alg: none
A token can claim {"alg":"none"} and carry an empty signature. A verifier that trusts the header will accept a forged, unsigned token. Reject none always. Pin the expected algorithm on your side.
Algorithm confusion (HS/RS)
If a server holds an RSA public key and calls a verify function that reads alg from the token, an attacker can sign an HS256 token using the public key as the HMAC secret. The fix is the same: the verifier decides the algorithm, the token does not.
Skipping exp
A token with no expiry is valid until the secret rotates. If it leaks, it is a permanent credential. Set exp to a short window and check it.
Weak or reused secrets
A short secret like secret is brute-forceable offline. An attacker with one valid token can guess the key and then mint any token. Use at least the required byte length of random data.
Putting secrets in claims
The payload is public. A password, an API key, or a full email placed in a claim is readable by anyone who intercepts the token.

Related tools

Tokens are one factor. If you also issue time-based one-time codes for a second factor, the TOTP / HOTP Authenticator generates the 6-digit codes and the otpauth:// strings that authenticator apps scan.

Frequently asked questions

Is a JWT encrypted?

No. The header and payload are Base64url-encoded, which is fully reversible with no key. Anyone can read them. The signature only proves that a secret holder produced the claims and that they have not changed. For confidentiality you need JWE.

Why does my signature not match the one another library produces?

Almost always the signing input differs. Whitespace or key ordering in the JSON, trailing Base64url padding, or the wrong byte encoding of the secret all change the input. HMAC over even a one-byte-different input gives a completely different tag.

What is a good expiry time?

For access tokens, minutes to an hour is typical. A 15-minute exp means a leaked token is useless after 15 minutes. Pair short access tokens with a separate, revocable refresh mechanism if you need longer sessions.

Can I revoke a JWT before it expires?

Not by itself. A signed token is valid until exp regardless of what happens on the server. Revocation requires extra state: a denylist of jti values, or rotating the signing secret, which invalidates every token at once.

HS256 or HS512?

HS256 is the practical default and is secure with a proper 32-byte secret. HS512 gives a 64-byte tag and needs a 64-byte secret, which raises token size for no meaningful gain against realistic attacks on the HMAC itself. Choose based on interoperability, not fear.