DevFormatLab
← Back to blog

Understanding JWT Tokens — A Practical Guide

By DevFormatLab Editorial·8 min read
JWTAuthenticationSecurityTutorial

JSON Web Tokens (JWTs) have become the de-facto format for stateless authentication in web APIs, OAuth 2.0 flows, and microservices. They look like three base64url strings joined by dots, but each of those three parts encodes a specific role in the trust model. After debugging thousands of JWT-related issues in production, we have collected the patterns that almost every failure falls into, and the recipes that fix them.

The three parts of a JWT

A JWT is three base64url-encoded segments separated by dots: header.payload.signature. Each segment has a precise job.

Header declares the algorithm and token type. The minimum useful header looks like:

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload carries the claims — assertions about the bearer. Registered claims per RFC 7519 have specific names (iss, sub, aud, exp, nbf, iat, jti); custom claims are allowed and commonly used (scope, roles, tenant, etc.).

Signature is the cryptographic proof. It is computed over base64url(header) + "." + base64url(payload) using the algorithm declared in header.alg, with a secret (for HMAC variants) or a private key (for RSA / ECDSA). The verifier recomputes the signature with the same input plus its own copy of the secret or public key, and if the recomputed signature matches the one in the token, the token is authentic.

The claims, one by one

The seven registered claims in RFC 7519 plus a handful of common idiomatic extensions. The JWT Decoder tool on this site reads them all and surfaces them in a readable panel.

  • iss (issuer): who minted the token. "https://auth.example.com" is typical. Validate against an allow-list; never trust a token whose iss is unexpected.
  • sub (subject): who the token is about — usually a user ID, an email, or an opaque identifier. Validate.
  • aud (audience): who the token is meant for. May be a string or an array. Validate against your service's identifier; reject tokens whose aud does not include you.
  • exp (expiration time): an epoch second after which the token must not be trusted. Reject tokens whose exp is in the past, with a small leeway (60s is typical) to handle clock skew.
  • nbf (not before): an epoch second before which the token must not be trusted. Useful for delayed-activation tokens.
  • iat (issued at): when the token was minted. Useful for cache busting and for the maximum-token-age policy.
  • jti (JWT ID): a unique identifier for this token, used by replay-detection systems. Required if your service implements a one-time-use policy.

HS256 vs RS256 vs ES256 — pick deliberately

The choice of algorithm is a security decision you only get to make once at the start of a project, so it is worth doing right.

HS256 / HS384 / HS512 (HMAC)
The same symmetric secret is used to sign and to verify. Fast, simple, no key infrastructure. The catch: anyone who can verify a token can also mint one. HS256 is appropriate when the issuer and the verifier are the same service or share a trusted key store. It is rarely the right choice when multiple services verify each other's tokens, because every verifier now has the power to forge tokens for every other verifier.
RS256 / RS384 / RS512 (RSA)
The signer holds a private key; the verifier holds a public key. The verifier can verify but cannot forge. This is the right choice for federated identity, single sign-on, OAuth 2.0 servers, and any case where the token consumer should not also be able to mint tokens. The trade-off is asymmetric-key infrastructure: key rotation, JWKS endpoints, and the operational burden of not letting the private key leak.
ES256 / ES384 / ES512 (ECDSA)
Elliptic-curve analog of RS256. Smaller signatures, comparable security, faster verification, slower signing. Most modern JWT deployments are migrating to ES256 for size and performance.
none
The token has no signature. Any consumer that accepts alg: none will accept a forged token with arbitrary claims. This option exists for debugging and should be removed from every production JWT library by configuration. The JWT Decoder on this site flags any token with alg: none so you cannot miss the warning.

The four common failure modes

Almost every production JWT bug we've seen falls into one of these four. The fix recipe is the same every time.

  1. Token expired. Client and server disagree on the current time, or the exp claim is in the past because the token was minted more than the configured lifetime ago. Decode the token; check exp; compare to the server clock. If the clock is wrong, fix NTP; if the token is expired, refresh.

  2. Wrong audience. A token minted for service A is being presented to service B. Decode the token; check aud; reject if it does not include your service's identifier. Most production-grade JWT libraries have an audience parameter; make sure every verify call passes it.

  3. Algorithm confusion. An attacker submits a token with alg: HS256 to a service that expects RS256, hoping the service will use the public key (intended for RS256 verification) as if it were an HMAC secret. The fix is twofold: enforce the algorithm at the verify step (verify(token, key, algorithms=["RS256"])), and never use the same key as both an HMAC secret and an RSA private key.

  4. Signature mismatch from base64url normalisation. JWT signatures are computed over the exact byte representation of base64url(header) + "." + base64url(payload); missing or extra padding, converting - and _ to + and /, or normalising Unicode before signing will all produce a different signature. Use a library that handles the canonical form; if you are debugging by hand, copy the exact segment bytes (not the decoded JSON) into your verifier.

How to debug in five minutes

The JWT Decoder on this site is built for exactly this scenario: paste the token, see the algorithm, the expiration, the audience, and every claim at a glance. For sign-and-verify debugging, combine it with the Base64 tool (to compare the exact byte representation of your header / payload segments) and the Hash Generator (to compute what the signature should be, given a known HMAC secret).

  1. Paste the failing token into the JWT Decoder. Note the algorithm and the expiration.
  2. If the algorithm is none, you have an emergency: someone has minted a token without a signature and a service is accepting it.
  3. If the token expired, refresh it and retry; if the token is alive, check the audience and issuer.
  4. If the audience and issuer are right but verification still fails, recompute the signature in the Hash Generator using your key and compare against the signature segment; a mismatch means base64url normalisation or padding drift.
  5. If the signature matches but the consumer still rejects, the consumer is probably checking nbf, iat, or a custom claim you forgot to set.

Closing

JWTs are simple in theory and treacherous in practice. The two safety nets we never deploy without: pin the algorithm at the verifier, and check exp with a small clock-skew window. Everything else — key rotation, audience isolation, replay protection — flows from those two. The reference: JWT Decoder for inspection, the JSON guide for the underlying serialisation rules, and RFC 7519 for the actual specification.

Related Tools