Security

Understanding JWT Tokens: A Practical Guide for Developers

A practical explanation of JWT headers, payload claims, signatures, and the right way to inspect tokens during development.

A practical explanation of JWT headers, payload claims, signatures, and the right way to inspect tokens during development.

This article focuses on the practical side of the workflow, including where developers usually get stuck, what to verify before trusting the result, and how the topic shows up in day-to-day implementation work.

What a JWT Actually Is

A JSON Web Token is a compact, URL-safe string that carries a set of claims between two parties, most commonly between an authentication server and your API. When you log in and your backend hands you a token, that token is the JWT. Your client stores it and sends it back on subsequent requests, usually in an Authorization header as a Bearer token, and the server uses it to confirm who you are without looking anything up in a session store.

The key insight that trips up newcomers is that a JWT is not encrypted by default. It is signed. Anyone who intercepts the token can read its contents. What they cannot do, without the secret or private key, is tamper with it and produce a valid signature. So a JWT answers the question "is this data authentic and unmodified?" but it does not answer "is this data secret?" Never put a password, a credit card number, or anything genuinely sensitive in the payload.

The Three Parts: Header, Payload, Signature

Every JWT is three Base64URL-encoded segments joined by dots, so it looks like xxxxx.yyyyy.zzzzz. The first segment is the header, the second is the payload, and the third is the signature. Because the dots are literal separators, you can split a token on the dot character and decode the first two parts with any Base64URL decoder.

The header is small. It typically reads alg HS256, typ JWT, declaring the signing algorithm and the token type. The payload holds the claims, which is the actual data, for example a sub of 1234567890, a name, an admin flag, an iat, and an exp. The signature is computed over the encoded header and payload using the algorithm named in the header, so changing a single character in the payload invalidates it.

It is worth noting that the header is attacker-controllable input. The infamous alg none and algorithm-confusion attacks both exploit servers that blindly trust the header to decide how to verify. A safe verifier pins the expected algorithm rather than reading it from the token.

Registered Claims You Will See Constantly

The JWT spec defines a handful of standard claim names, each a three-letter key, designed to be interoperable across systems. The most important ones are: iss (issuer, who minted the token), sub (subject, usually the user ID), aud (audience, who the token is intended for), exp (expiration time), nbf (not before), and iat (issued at).

The time-based claims are NumericDate values, meaning seconds since the Unix epoch, not milliseconds and not ISO strings. So an exp of 1718413200 corresponds to a specific UTC instant. A common bug is computing these in JavaScript with Date.now(), which returns milliseconds, and forgetting to divide by 1000, producing tokens that expire roughly fifty thousand years from now.

Beyond the registered claims, you are free to add your own custom claims such as roles, tenant IDs, or feature flags. Keep them small. Tokens travel on every request, and a bloated payload inflates the size of every header you send.

How Expiration and Time Claims Behave

The exp claim sets a hard deadline. Once the current time passes that value, a correct verifier rejects the token outright. This is why JWTs are typically short-lived, often fifteen minutes to an hour, paired with a longer-lived refresh token that can mint new access tokens. The nbf claim is the mirror image: the token is not valid until that time arrives, which is useful for tokens issued in advance.

A subtle real-world issue is clock skew. If the issuing server and the verifying server disagree by even a few seconds, a token can appear expired or not-yet-valid right at the boundary. Most libraries accept a small leeway parameter, often thirty to sixty seconds, to absorb this. Setting it too high, however, weakens the guarantee that expired tokens are actually rejected.

Remember that expiration is enforced by the verifier, not by the token itself. A token does not delete itself when it expires. If your server never checks exp, an old token works forever, which is one of the more dangerous oversights in JWT-based systems.

Decoding Is Not Verification

This distinction is the single most important thing to internalize. Decoding a JWT just Base64URL-decodes the header and payload so you can read them. It involves no secret and proves nothing about authenticity. Verification recomputes the signature over the header and payload using your secret or public key, compares it to the signature in the token, and then validates the claims such as exp, aud, and iss.

A worryingly common mistake is reading the payload, extracting a userId or a role, and trusting it, all without ever verifying the signature. In Node, calling jwt.decode(token) instead of jwt.verify(token, secret) does exactly this. An attacker can hand-craft a token with admin set to true, and an application that only decodes will happily grant access.

The rule is simple: decode freely for display or debugging, but make an authorization decision only on data that came out of a successful verify call. Treat anything you merely decoded as untrusted user input.

Choosing and Pinning the Algorithm

Signing algorithms fall into two families. HMAC variants like HS256 use a single shared secret for both signing and verifying, which is fine when the same trusted service does both. RSA and ECDSA variants like RS256 and ES256 use a private key to sign and a public key to verify, which is the right choice when many independent services need to validate tokens but should not be able to mint them.

Algorithm confusion attacks exploit a mismatch here. If a server holds an RSA public key and a sloppy library lets the token dictate the algorithm, an attacker can switch the header to HS256 and sign the token using the public key as if it were an HMAC secret. Because the public key is, by definition, public, the forged token verifies. The defense is to always pass an explicit allowlist of acceptable algorithms to your verify call rather than letting the token choose.

Likewise, reject alg none categorically. A token with no signature is just a claim that anyone can make, and any code path that accepts it is effectively an open door.

Debugging Tokens Safely

When a token is being rejected, the fastest first step is to decode it and read the payload. You do not need a website for this. On the command line you can split the token on dots and Base64-decode the middle segment, padding it if needed. The point is to inspect exp against the current epoch time, confirm aud matches what your API expects, and check that iss is the issuer you trust.

A critical safety note: avoid pasting real production tokens into random online JWT decoders. A live access token is a bearer credential, equivalent to a password for its lifetime. Anyone who captures it can impersonate the user until it expires. For debugging, prefer a local tool, a test token, or a token you have already expired. If you must use an online tool, use one that decodes entirely client-side and never with a token that grants real access.

Typical findings during debugging are mundane but easy to miss: an exp already in the past, an aud pointing at staging while hitting the production verifier, a clock skew of a few seconds, or a token signed with last week's rotated key. Checking these four things resolves the large majority of verification failures.

Practical Habits That Prevent Trouble

Keep access tokens short-lived and lean on refresh tokens for longevity, so a leaked token is dangerous only briefly. Always verify with an explicit algorithm allowlist, validate exp, aud, and iss on every request, and store secrets and private keys in a real secrets manager rather than in source or config files committed to your repository.

On the client, where you store the token matters. localStorage is readable by any JavaScript on the page, which makes it a juicy target for cross-site scripting. An HttpOnly, Secure cookie is invisible to scripts, though it brings cross-site request forgery considerations of its own. There is no universally correct answer, only the right trade-off for your threat model.

Finally, plan for revocation before you need it. Stateless JWTs cannot be un-issued, so a compromised token stays valid until it expires. Mitigations include very short lifetimes, a server-side denylist of revoked token IDs keyed on a jti claim, or rotating signing keys to invalidate everything at once. Knowing which of these you rely on, ahead of an incident, is what separates a calm response from a scramble.