All articles
Encoders & Decoders
41 min readBy DevUtilX Team

JSON Web Tokens (JWT) Explained: Structure, Decoding and Pitfalls

Learn how JWTs are built, why decoding is not verifying, and how to validate signatures, claims and keys safely, with tested Node.js and Python examples.

JSON Web Tokens (JWT) Explained: Structure, Decoding and Pitfalls

You paste a long string of letters and dots into a login debugger, and a readable payload appears. It looks like magic, and it invites a dangerous conclusion: that the token is "encrypted", or that if it decodes, it must be valid. Neither is true. A JSON Web Token (JWT) is a signed, readable envelope, and most real-world JWT security problems come from misunderstanding that one sentence. This guide is for developers who build or debug APIs, single-page apps and mobile backends. You will learn what a JWT is, how its three parts are built, how to create and verify one by hand, which claims matter, how signing algorithms differ, and what separates decoding from validating. You will also see the classic pitfalls, a troubleshooting table that maps symptoms to fixes, and tested code in Node.js, Python and the shell.

Everything here is defensive. All secrets and tokens in the examples are fake demo values, and the code is written to help you verify tokens correctly, not to bypass anyone's checks.

What is a JSON Web Token?

A JWT, pronounced "jot", is a compact, URL-safe string that carries a set of claims, which are statements such as "this token was issued by this server, for this user, and expires at this time". The format is defined in RFC 7519. A typical token looks like three chunks of text separated by dots:

xxxxx.yyyyy.zzzzz

Each chunk is Base64url-encoded data. The first is a JSON header that describes how the token is protected. The second is a JSON payload that holds the claims. The third is a signature, or an authentication tag, that lets a recipient detect tampering. Because the characters are limited to letters, digits, hyphen, underscore and the dot separator, a JWT can travel safely in an HTTP header, a URL query string, a form field or a cookie without extra escaping.

Two properties make JWTs popular. First, they are self-contained: a server that trusts the signing key can read the identity and permissions straight from the token, with no database lookup. Second, they are portable across languages, because every mainstream platform can parse JSON, compute an HMAC and decode Base64url.

Those same properties create the main risks. Self-contained means the data lives in the token, where the client can read and copy it. Portable means every team can implement it slightly differently, and the gaps between implementations are where vulnerabilities live.

A short history

JWT grew out of the OAuth and OpenID work of the early 2010s, when web services needed a small, standard way to pass signed identity information between parties. The effort produced a family of IETF specifications, sometimes called the JOSE suite, which stands for JSON Object Signing and Encryption:

  • JWS (JSON Web Signature, RFC 7515) defines how to sign content.
  • JWE (JSON Web Encryption, RFC 7516) defines how to encrypt content.
  • JWK (JSON Web Key, RFC 7517) defines how to write cryptographic keys as JSON.
  • JWA (JSON Web Algorithms, RFC 7518) lists the algorithm names such as HS256 and RS256.
  • JWT (RFC 7519) defines the claims set and how it rides inside a JWS or a JWE.

Later guidance, notably the JWT best current practices in RFC 8725, documented the mistakes that implementers kept making and told them how to avoid each one. This article follows that spirit. If you want a quick overview of the format from a neutral source, the Wikipedia article on JSON Web Token and the jwt.io introduction are good starting points.

Strictly speaking, "a JWT" is a claims set, and the thing you pass around is usually a JWS that contains it. In everyday speech, the signed three-part string is just called a JWT, and this guide does the same.

Anatomy of a token

Let us take a token apart one piece at a time. The sections below build one from scratch, so every byte is accounted for.

Part one: the header

The header is a small JSON object. For an HMAC-signed token it is:

{"alg":"HS256","typ":"JWT"}
  • alg names the signing algorithm. HS256 means HMAC using SHA-256.
  • typ declares the media type of the token, conventionally JWT. It is optional, but common.

Other header fields appear when needed. kid (key ID) tells the recipient which key signed the token, which is essential when a server rotates keys. Some tokens add jku or x5u to point at a key location, and, as you will see in the pitfalls section, a verifier must never trust such a pointer blindly.

Part two: the payload

The payload is another JSON object, the claims set. A realistic example:

{
  "iss": "https://auth.example.test",
  "sub": "user-1001",
  "aud": "demo-api",
  "iat": 1700000000,
  "nbf": 1700000000,
  "exp": 1700000900,
  "jti": "demo-0001",
  "role": "reader"
}

The first seven names are registered claims with standard meanings, which the next sections explain. role is a custom claim that this imaginary application invented for its own use. Times are expressed as NumericDate values: the number of seconds since 1 January 1970 UTC, without milliseconds. In the example, 1700000000 is 22:13:20 UTC on 14 November 2023, and exp is 900 seconds, or 15 minutes, later.

Part three: the signature

For an HMAC token, the signature is computed over the first two parts exactly as they appear in the string:

HMAC-SHA256( secret, base64url(header) + "." + base64url(payload) )

The result is 32 raw bytes, which are then Base64url-encoded into 43 characters. The signature covers the encoded text, not the decoded JSON, so a verifier must hash the original string it received and not a re-serialised version of the JSON. That detail prevents a whole class of bugs.

Base64url, and why not plain Base64

Standard Base64 uses + and /, and pads with =. All three characters have special meaning in URLs and form encoding. Base64url, defined in RFC 4648, swaps + for - and / for _, and JWT drops the padding. We can see the difference on three bytes, 0xfb 0xff 0xfe, measured with Node.js:

Encoding Output for bytes fb ff fe
Standard Base64 +//+
Base64url -__-

Notice that Base64url is an encoding and not encryption. There is no key. Anyone can reverse it in a second, which is the foundation of everything in the "decoding is not verifying" discussion below.

How big is a token?

The token we build in this guide has parts of 36, 207 and 43 characters. Adding the two dots gives 288 characters in total. The header is 27 bytes of JSON that expand to 36 characters, because Base64 turns every three bytes into four characters. The payload is 155 bytes of JSON that become 207 characters. That one-third overhead is a real cost, and it is why trimming claims matters when the token travels with every request.

Worked example: build and verify a token by hand

This section constructs a complete token with the fake secret demo-secret-do-not-use-in-production. The numbers below come from running the scripts at the end of this article, and the Node.js, Python and openssl versions all produced the same signature.

Step 1. Serialise the header. The compact JSON {"alg":"HS256","typ":"JWT"} is 27 bytes. Base64url gives:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

Many HS256 tokens begin with exactly this string, because most libraries emit the same two header fields in the same order. Recognising eyJhbGciOi at the start of a string is a handy way to spot a JWT in logs: it is the Base64url form of {"alg".

Step 2. Serialise the payload. The compact claims JSON is 155 bytes, and its Base64url form is 207 characters:

eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS50ZXN0Iiwic3ViIjoidXNlci0xMDAxIiwiYXVkIjoiZGVtby1hcGkiLCJpYXQiOjE3MDAwMDAwMDAsIm5iZiI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDAwOTAwLCJqdGkiOiJkZW1vLTAwMDEiLCJyb2xlIjoicmVhZGVyIn0

Step 3. Build the signing input. Join the two with a dot. This exact text is what the HMAC will process.

Step 4. Compute the signature. HMAC-SHA256 with the secret over the signing input gives 32 bytes, which become:

SwxS3FjpkNshH0q9B4gina4-v_bARjfirmBzjmyRZ80

Step 5. Assemble the token. The final string is the three parts joined by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS50ZXN0Iiwic3ViIjoidXNlci0xMDAxIiwiYXVkIjoiZGVtby1hcGkiLCJpYXQiOjE3MDAwMDAwMDAsIm5iZiI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDAwOTAwLCJqdGkiOiJkZW1vLTAwMDEiLCJyb2xlIjoicmVhZGVyIn0.SwxS3FjpkNshH0q9B4gina4-v_bARjfirmBzjmyRZ80

You can paste this demo token into the JWT Decoder to see the header and claims, and into the JWT Validator with the demo secret to see a signature check succeed. Both tools run in your browser.

Step 6. Verify it. A recipient reverses the process. It splits on dots, decodes the header and checks that alg is on its allow-list, recomputes the HMAC over the first two parts, compares it to the third part in constant time, and only then trusts the claims and checks them against the clock and its own expectations.

What tampering looks like

Suppose an attacker takes our token, decodes the payload, changes "role":"reader" to "role":"admin", re-encodes it and keeps the original signature. When we ran this experiment, the verifier answered invalid signature. The signature was computed over the old payload, so it no longer matches. Without the secret, the attacker cannot produce a matching tag for the new payload.

That is the entire security guarantee of an HS256 token: integrity and authenticity under the assumption that the secret stays secret. It is not confidentiality.

Registered claims and custom claims

RFC 7519 reserves a handful of short claim names. None of them is mandatory by the format itself, but your application should decide which ones it requires and enforce them.

Claim Name Meaning Typical check
iss Issuer Who created and signed the token Must equal an expected issuer string
sub Subject Who the token is about, often a user ID Use as the stable identifier
aud Audience Who the token is intended for Your service's identifier must be present
exp Expiration time After this moment, reject the token Current time must be earlier than exp
nbf Not before Before this moment, reject the token Current time must not be earlier than nbf
iat Issued at When the token was created Useful for age limits and auditing
jti JWT ID A unique identifier for this token Used for revocation lists and replay detection

Reading each claim carefully

iss identifies the authority. If your application trusts more than one identity provider, accepting any issuer is a bug: a token signed by a different, perfectly legitimate provider should not be accepted for your users. Compare iss against an exact value, and select the verification key based on that trusted issuer.

sub is the principal. Pick an identifier that never changes and is never reused, such as an internal user ID. Email addresses change, and reassigned addresses have caused account takeovers in systems that used them as identity.

aud may be a single string or an array of strings. A recipient must check that its own identifier appears in it. Without that check, a token minted for one API can be replayed against another API that shares the same signing key. This is called a confused-deputy or cross-service replay problem.

exp limits the damage of a stolen token. Short lifetimes, often minutes for access tokens, are the main defence available when you cannot revoke a stateless token.

nbf supports tokens that become valid in the future, for example a scheduled grant. Many systems omit it. If present, enforce it.

iat records creation time. It does not grant any validity by itself, but it lets a server say "reject tokens older than an hour regardless of exp", which is useful after a security incident.

jti gives each token a unique name. A server can store the IDs of revoked tokens, or the IDs of tokens already used once, and refuse them. This reintroduces state, which is a trade-off we discuss later.

Custom claims

Anything else in the payload is a custom claim: role, tenant, scope, email, locale. Two rules keep them healthy.

  1. Avoid collisions. Use specific names, or namespace them with a URL-like prefix when your tokens may mix with others, so that your claim cannot clash with a registered or standardised one.
  2. Keep them small and non-sensitive. The payload is readable by anyone who holds the token, and it is sent on every request. Put in only what the receiving service needs to make an authorisation decision.

A claim like "role":"admin" is a statement by the issuer at issue time. If the user's role changes five minutes later, the token still says admin until it expires. Design your lifetimes with that lag in mind.

Signing algorithms at a conceptual level

The alg header selects how the signature is made. The details of the mathematics are beyond this guide, but the practical differences matter for design.

HS256: a shared secret

HS256 uses HMAC with SHA-256. The same secret both creates and verifies the signature. That is simple and fast, and it suits a single service, or a small set of services owned by one team, where sharing the secret is acceptable.

The weakness is the sharing itself. Every party that can verify can also forge, because the verifying key is the signing key. If you hand the secret to a partner, a mobile app or a third-party service so that it can check tokens, you have also handed it the power to mint tokens. Also, HMAC is only as strong as the secret. A short or guessable secret can be attacked offline: an attacker with one valid token can try candidate secrets at high speed, without ever contacting your server, until one reproduces the signature. Use a long random secret, at least as many bits as the hash output (256 bits, or 32 random bytes, for HS256), generated by a cryptographically secure random generator and never typed by a human.

RS256 and ES256: asymmetric signatures

RS256 uses RSA with SHA-256, and ES256 uses ECDSA over the P-256 curve with SHA-256. These are public-key algorithms. A private key signs, and a matching public key verifies. The issuer keeps the private key secret and publishes the public key, so any number of services can verify tokens without being able to create them.

This split is the reason to prefer asymmetric algorithms whenever tokens cross a trust boundary: an identity provider and many separate APIs, or a backend and untrusted clients that need to check a token. The costs are larger tokens, larger keys and more complex key management.

We measured the signature sizes on this sandbox with Node.js for the raw signature bytes:

Algorithm Raw signature Base64url signature length
HS256 32 bytes 43 characters
ES256 (P-256, IEEE P1363 format) 64 bytes 86 characters
RS256 (2048-bit key) 256 bytes 342 characters

So an RS256 token carries about 300 more characters than an HS256 token with the same claims. ES256 sits in between and is often chosen when token size matters. Note that JWT's ES256 signature format is the fixed-length concatenation of the two integers r and s, not the DER format that some libraries produce by default. In Node.js you request it with dsaEncoding: "ieee-p1363". Mixing formats is a common cause of "signature invalid" errors between libraries.

Other algorithms

The algorithm registry also lists HS384 and HS512, RS384, RS512, PS256 and its variants, ES384, ES512, and EdDSA. For a new system, a modern curve-based option such as ES256 or EdDSA is a reasonable default if your libraries support it, and RS256 remains the most widely supported. What matters more than the exact choice is that your verifier accepts only the algorithm or small set of algorithms you decided on in advance.

The none algorithm

The specification defines an "unsecured JWT" whose header says "alg":"none" and whose signature part is empty. It exists for situations where integrity is guaranteed by some other layer. In an API that authenticates callers with tokens, it is a trap. If a verifier reads alg from the token and honours it, then a token that claims none passes with no signature at all.

Here is the header that describes such a token, and its Base64url form measured in Node.js:

{"alg":"none","typ":"JWT"}  ->  eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0

You should recognise it for defensive reasons: if you see a token beginning with that string reach a service that expects signed tokens, the correct behaviour is rejection. In our verifier, a token like this produced algorithm not allowed: none. The fix is structural and simple: the verifier decides the allowed algorithms, never the token. Most mature libraries now reject none by default, but old versions and hand-rolled code do not, so confirm it with a test.

Algorithm confusion

A related attack targets services that accept both HS256 and RS256. Imagine a server that verifies RS256 tokens using a public key, and whose verification function picks the algorithm from the header. An attacker sets alg to HS256, and the vulnerable code then uses the public key, which is not secret, as the HMAC secret. The attacker can compute a valid HMAC with that known value. The defence is the same principle: bind each key to one algorithm, and refuse any token whose header disagrees. Never pass a key to a verification function without also telling it which algorithm that key is for.

Signed or encrypted: JWS versus JWE

Everything so far describes a signed token, a JWS. Its payload is not hidden. We stress this because it is the most common misunderstanding about JWTs.

Run the following in any shell, with the demo token from earlier in $T, and the claims appear:

echo "$T" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo

There is no key involved. Whoever sees the token sees the claims. This includes the browser extension that reads your storage, the proxy that logs your headers, the support engineer who pastes your token into a bug report, and the person who finds a token in a screenshot.

A JWE, defined in RFC 7516, encrypts the payload. It has five dot-separated parts instead of three: a protected header, an encrypted key, an initialisation vector, the ciphertext and an authentication tag. Only the holder of the right key can read the contents. JWE is useful when a claim must be confidential from the bearer or from intermediaries, but it is considerably more complex, and fewer libraries implement it well. For most web applications the better course is to avoid putting confidential data in the token and to keep it on the server, referenced by an ID.

Treat these as rules:

  1. A normal three-part JWT is readable by anyone who holds it.
  2. Never put passwords, secrets, API keys, national ID numbers, full payment data or private personal data in it.
  3. Be cautious with email addresses and names. They are personal data, and tokens end up in logs.
  4. If you genuinely need an encrypted token, use JWE through a well-maintained library, and still minimise what goes into it.

You can tell the two forms apart by counting the dots: two dots means signed (JWS), four dots means encrypted (JWE).

Decoding is not verifying

This is the single most important idea in the whole guide, so it has its own section.

Decoding means splitting the token on dots and Base64url-decoding the first two parts into JSON. It needs no key and tells you what the token claims. Verifying means checking the signature with the right key and algorithm and then checking the claims against your policy. It tells you whether the claims can be trusted.

The two operations are often confused because many libraries expose both, and the decode function is easier to call. A function called decode or parseClaims in a library does not validate anything. If your authorisation code reads role from a decoded token and acts on it, an attacker can write any token they like, and your code will believe it.

When is decoding by itself fine?

  • Debugging. Inspecting a token to understand why a request failed. The JWT Decoder is built for exactly this.
  • Client-side display. A single-page app may decode the payload to show the user's name or know when the token will expire. This is a convenience only. The client cannot trust the token for security, because the client is not the party that enforces access.
  • Routing hints. Reading iss or kid to choose which key to use for verification. You must still verify afterwards, and you must treat the iss value as untrusted input until the signature checks out against a key you chose from a trusted list.

When is it never fine? Any server-side decision about access, identity or permissions.

The validation checklist

A correct verifier does these steps in this order. Each step is cheap, and skipping any of them has led to real breaches.

  1. Check the structure. Exactly three parts, each valid Base64url, header and payload valid JSON objects. Reject anything else, and put a limit on token size before you parse it.
  2. Check the algorithm against an allow-list. The server's configuration, not the token, decides. Accept one algorithm per key. Reject none.
  3. Choose the key from a trusted source. Use a configured key, or look up kid in a key set you obtained from a trusted issuer. Never fetch a key from a URL named inside the token.
  4. Verify the signature, recomputing it over the original header.payload text and comparing in constant time where the library does not do it for you.
  5. Check exp. Reject if the current time is later than exp, allowing small clock skew. Treat a missing exp as an error for access tokens.
  6. Check nbf, if present, with the same skew allowance.
  7. Check iss against an exact expected value.
  8. Check aud and confirm your service's identifier is in it.
  9. Check application rules. Token type or purpose, scope or role claims, maximum age based on iat, and a revocation list lookup by jti if you keep one.

Clock skew

Servers do not agree perfectly on the time. If the issuer's clock runs a few seconds ahead of the verifier's, a freshly issued token can appear to have an nbf in the future. A small leeway, commonly a few tens of seconds, absorbs this. Our reference verifier took a 30-second leeway, and a check at 20 seconds past expiry still passed, while a check at 100 seconds past expiry failed with token expired. Keep leeway small: it effectively extends every token's lifetime by that amount. If you need more than a minute or so, fix the clocks with NTP instead.

Where to fail closed

Every check should reject on doubt. A missing aud, a non-numeric exp, an unknown kid and an unexpected algorithm are all rejections, never warnings. Return a generic authentication error to the caller and write the specific reason to your server logs, so that attackers learn nothing about which check they failed.

Real-world use cases

API access tokens. After a user signs in, an authorisation server issues a short-lived JWT, and the client sends it in an Authorization: Bearer header. The API verifies it locally and reads the subject and scopes. This is the most common use, and it works well when the API cannot or should not query a central session store on every call.

OpenID Connect ID tokens. OpenID Connect, the identity layer on top of OAuth 2.0, defines the ID token as a JWT that tells a client who the user is. The client verifies it against the provider's published keys and checks the audience, which is the client's own identifier.

Service-to-service calls. One microservice proves its identity to another with a signed token, often with an asymmetric key so that the callee can verify without holding a signing secret.

Single sign-on across domains. A central login service issues a signed assertion that other applications accept. Because the token is verifiable offline, the applications need no shared session storage.

One-time links. A password reset or email confirmation link can embed a signed token with a short exp and a narrow purpose claim. The server verifies it when the user clicks. Add a single-use rule through jti, because a signature alone does not stop the same link from being used twice.

Webhook and callback authenticity. A sender signs the payload or a token so the receiver can confirm origin.

Short-lived upload or download grants. A service signs a token that permits one specific action on one resource for a few minutes, and a storage or edge layer verifies it without calling back.

JWTs fit these cases because the token is small, verifiable offline and short-lived. They fit poorly when you need instant revocation, large server-side session state, or confidentiality, and the next section explains how to judge that.

Sessions versus JWTs

"Should we use JWTs for login?" has no universal answer. Compare the two models on the properties that matter.

A server-side session stores state on the server and gives the browser an opaque random ID, usually in a cookie. Each request carries the ID, and the server looks it up. Logging out or banning a user is trivial: delete the record, and the next request fails.

A stateless JWT stores the state in the token. The server verifies a signature and reads the claims, with no lookup. That scales across services easily, but you cannot cancel a token before its exp unless you add a lookup back, such as a denylist.

Concern Server session (opaque ID) Stateless JWT
Revoke one user immediately Easy: delete the record Hard: needs a denylist or short lifetimes
Per-request lookup Yes, a store read No, only a signature check
Works across many services Needs a shared store Natural with a shared key or public key
Token size on the wire Small ID Hundreds of characters or more
Contents visible to client No Yes, unless encrypted
Permission changes take effect Immediately At next token refresh
Complexity of key handling Low Higher: rotation, algorithms, validation

A simple web application with one backend rarely needs JWTs for browser login. A cookie holding an opaque session ID is easier to secure and easier to revoke. JWTs earn their place when several independent services must trust a token without calling a central one, when the issuer is a separate identity provider, or when tokens are short-lived grants rather than long sessions.

Many teams land on a hybrid: a short-lived JWT access token verified locally, backed by a server-side refresh record that can be revoked. That hybrid is the subject of the next section.

Refresh tokens and rotation

A short access-token lifetime limits the damage of a leak but would force users to sign in every few minutes. A refresh token solves this. It is a long-lived credential whose only job is to obtain a new access token. The client keeps it, presents it to the authorisation server when the access token expires, and receives a fresh access token.

Several design choices decide how safe this is.

Make the refresh token opaque and stored server-side. It does not need to be a JWT. A random value that the server looks up lets you revoke it by deleting a row, which restores the control that stateless access tokens lack.

Rotate on every use. Each time a refresh token is exchanged, issue a new refresh token and invalidate the old one. A legitimate client always holds the latest one.

Detect reuse. If an already-used refresh token appears again, either the client or an attacker has a stale copy. Treat it as a theft signal and revoke the whole token family, forcing the user to sign in again. This is the key benefit of rotation: theft becomes detectable.

Bind and limit. Set an absolute maximum session length, so that rotation cannot extend a login forever, and consider an inactivity timeout.

Protect storage. In a browser, a refresh token is the most valuable thing you hold. Put it in an HttpOnly, Secure cookie with a restrictive SameSite setting, scoped to the refresh endpoint's path, so scripts cannot read it.

Access tokens stay short, commonly minutes, and the refresh flow handles continuity. When a user's role changes, the new access token carries the new role at the next refresh, so your maximum staleness equals the access-token lifetime.

Key rotation, kid and JWKS

Keys must change eventually: because of a suspected leak, an employee leaving, a policy, or a cryptographic recommendation. Rotation in a system with many verifiers needs a plan, and JOSE provides the pieces.

  • Each signing key gets an identifier, placed in the token's kid header.
  • The issuer publishes the public keys in a JSON Web Key Set (JWKS), a JSON document that lists keys in the JWK format of RFC 7517. Verifiers fetch it from a URL they trust, typically configured in advance or discovered from the issuer's metadata.
  • To rotate, the issuer adds the new public key to the set, waits for verifiers to refresh their caches, starts signing with the new key, and later removes the old key once all tokens signed with it have expired.

On the verifier side, this means three habits:

  1. Look up the key by kid, but only among keys from your trusted issuer. An unknown kid is a rejection, though it can trigger a rate-limited refresh of the key set for the case of a recent rotation.
  2. Cache the key set with a sensible lifetime, so that you do not fetch it on every request and do not break when the endpoint is briefly down.
  3. Never obey key locations in the token. The jku, x5u and embedded jwk header parameters let a token point at or carry its own key. A verifier that follows them verifies the attacker's signature using the attacker's key. Ignore those fields unless they match an explicit allow-list you control.

Symmetric HS256 systems can rotate too, using kid to choose among a small list of secrets held by the verifier. The difference is that every secret must be distributed to every verifier, with all the exposure that implies, which is another reason asymmetric keys scale better across teams.

Common mistakes and pitfalls

These are the errors that show up repeatedly in audits and incident reports. Each comes with the fix.

Treating decoded as trusted

Reading claims without verifying the signature. Fix: use the library's verify function, and make sure no code path reaches authorisation logic with unverified claims. A lint rule or code review checklist that bans bare decode in server code is cheap protection.

Letting the token choose the algorithm

Passing the header's alg to the verifier. This enables both the none bypass and algorithm confusion. Fix: configure the allowed algorithms explicitly on the verifier, and use a separate key object per algorithm.

Weak HS256 secrets

Secrets such as a project name, a dictionary word or a short string. An attacker with a single token can test guesses offline at high speed. Fix: generate 32 or more random bytes, store them in a secret manager, and rotate them. Never reuse the same secret across environments, and never commit one to a repository.

Putting sensitive data in the payload

Because the payload is only Base64url-encoded, secrets placed in it are exposed to everyone who holds the token, and to every log that records it. Fix: keep claims minimal. Use an opaque identifier and look up sensitive attributes on the server.

Long-lived access tokens

A token valid for a month is a month-long credential if stolen, and without revocation, nothing can stop it. Fix: make access tokens short-lived and use refresh tokens with rotation.

No revocation plan

A stateless token stays valid until exp. Users change passwords, accounts are compromised and employees leave, and sometimes you need a token to stop working at once. Fix: short lifetimes plus one or more of: a jti denylist checked on sensitive operations, a per-user "tokens issued before" timestamp compared to iat, or a server-side refresh record you can delete.

Storing tokens where scripts can steal them

If a browser app keeps its token in localStorage or sessionStorage, any script that runs on the page can read it. A single cross-site scripting hole, or a compromised third-party script, then hands over the token. An HttpOnly cookie cannot be read by JavaScript, so that theft route disappears. Cookies carry their own risk, cross-site request forgery, which you counter with SameSite settings, anti-CSRF tokens and checking the request origin. Neither option is free, and the MDN guide to HTTP cookies explains the attributes in detail. For browser login, an HttpOnly, Secure, SameSite cookie is generally the safer default. Native mobile apps should use the platform's secure credential storage, not plain preferences files.

Skipping aud and iss

Accepting any validly signed token, even one issued for a different service or by a different issuer. Fix: check both against exact expected values.

Trusting header-supplied keys

Following jku, x5u or embedded jwk values. Fix: ignore them, and select keys only from configuration or a trusted JWKS.

Comparing signatures with ordinary equality

A normal string comparison can return early at the first differing byte. In theory the timing difference leaks information, and the fix costs nothing. Fix: use the constant-time comparison from your platform, such as crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python, or rely on a library that does so.

Mixing token types

An ID token proves who signed in. An access token grants access to an API. If your API accepts an ID token as an access token, you have blurred two purposes. Fix: put a purpose claim in the token, or use different audiences and issuers per token type, and check it.

Putting tokens in URLs

A query-string token ends up in browser history, server logs, analytics and the Referer header sent to other sites. Fix: send tokens in the Authorization header or a cookie. For one-time links where a URL is unavoidable, give them a very short exp and make them single-use.

Unbounded token size

Stuffing every permission into the token makes headers large, and servers and proxies have header-size limits that differ and can reject big requests. A cookie is commonly limited to roughly 4 KB per cookie in browsers. Fix: carry compact scopes or roles, and look up detailed permissions server-side.

Trusting typ or cty blindly

Header fields describing the type are hints. They do not replace checks on iss, aud and your own purpose claim. Fix: validate explicit claims, not assumed ones.

Step-by-step: inspect and validate a JWT with DevUtilX

When a request fails with 401 and you need to know why, two browser tools save time. Both run locally in your browser and do not upload the token anywhere. Still, only paste real production tokens if your policy allows it, and prefer a token from a test environment.

Decode a token

  1. Open the JWT Decoder.
  2. Paste the token into the input box. For practice, use the demo token from the worked example.
  3. Read the header. Check alg, and look for kid.
  4. Read the payload. Look at iss, aud and sub, and compare them with what your API expects.
  5. Look at the time claims. The tool shows exp, iat and nbf as readable dates, so you can see at a glance whether the token has expired or is not yet valid.
  6. Note what is missing. A token without exp or aud is already suspicious.

Remember what you have done: you have decoded the token, and you have learned nothing yet about whether it is authentic.

Validate the signature

  1. Open the JWT Validator.
  2. Paste the token, and provide the verification key. For an HS256 token, that is the shared secret. For RS256 or ES256, use the public key. With the demo token, the secret is demo-secret-do-not-use-in-production.
  3. Run the check. A correct key reports a valid signature, and a wrong one reports a failure. Try changing one character of the payload and confirm that validation fails.
  4. Review the claim checks. Confirm that exp has not passed and that nbf, iss and aud look right for your service.

Never enter a real production signing secret into any website, including a tool that claims to run locally, unless you have verified it yourself and your security policy allows it. For production secrets, use the verification code in your own environment, like the examples in the next section.

A debugging workflow

When an integration fails, work from the outside in:

  1. Decode the token and compare iss, aud, exp and nbf with the expected values.
  2. Check the algorithm and kid against your service's configuration.
  3. Validate with the key your service uses. If the signature fails here, you have a key or algorithm mismatch.
  4. Only after that, look at your application's authorisation rules.

If the token is attached to a JSON request body you are also debugging, the JSON Formatter guide explains how to read and clean the surrounding payload, and our guide to minifying JSON and payload size explains why tokens that travel inside JSON are worth keeping small.

Code examples: build, sign, decode and verify an HS256 token

The following programs were run in the sandbox used to write this article, with Node.js and Python 3. They use only the standard library, and they use a fake secret. They are teaching code that shows the logic of an HS256 verifier. In production, prefer a maintained library that has been reviewed and kept up to date, and configure it with the same rules.

Node.js

const crypto = require("crypto");

const b64u = (buf) => Buffer.from(buf).toString("base64url");
const fromB64u = (s) => Buffer.from(s, "base64url");

function sign(payload, secret) {
  const header = { alg: "HS256", typ: "JWT" };
  const input = b64u(JSON.stringify(header)) + "." + b64u(JSON.stringify(payload));
  const sig = crypto.createHmac("sha256", secret).update(input).digest();
  return input + "." + b64u(sig);
}

function decode(token) {
  const [h, p, s] = token.split(".");
  return { header: JSON.parse(fromB64u(h)), payload: JSON.parse(fromB64u(p)), signature: s };
}

function verify(token, secret, opts, now = Math.floor(Date.now() / 1000)) {
  const parts = token.split(".");
  if (parts.length !== 3) throw new Error("malformed token");
  const header = JSON.parse(fromB64u(parts[0]));
  if (header.alg !== "HS256") throw new Error("algorithm not allowed: " + header.alg);
  const expected = crypto.createHmac("sha256", secret).update(parts[0] + "." + parts[1]).digest();
  const given = fromB64u(parts[2]);
  if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected))
    throw new Error("invalid signature");
  const p = JSON.parse(fromB64u(parts[1]));
  const skew = opts.leeway ?? 0;
  if (typeof p.exp !== "number") throw new Error("missing exp");
  if (now > p.exp + skew) throw new Error("token expired");
  if (typeof p.nbf === "number" && now < p.nbf - skew) throw new Error("token not yet valid");
  if (p.iss !== opts.issuer) throw new Error("issuer mismatch");
  const aud = Array.isArray(p.aud) ? p.aud : [p.aud];
  if (!aud.includes(opts.audience)) throw new Error("audience mismatch");
  return p;
}

const SECRET = "demo-secret-do-not-use-in-production";
const payload = {
  iss: "https://auth.example.test", sub: "user-1001", aud: "demo-api",
  iat: 1700000000, nbf: 1700000000, exp: 1700000900, jti: "demo-0001", role: "reader",
};
const token = sign(payload, SECRET);
const opts = { issuer: "https://auth.example.test", audience: "demo-api", leeway: 30 };
console.log(token);
console.log(JSON.stringify(verify(token, SECRET, opts, 1700000100)));

Running the full script, which also exercises failure cases, printed these results. The now value is passed in explicitly so the output is reproducible.

Scenario Clock used Result
Valid token 1700000100 Claims returned
Expired 1700001000 token expired
Within leeway after expiry 1700000920 Accepted (20 seconds past exp, 30 allowed)
Wrong secret 1700000100 invalid signature
Wrong audience 1700000100 audience mismatch
Too early 1699999900 token not yet valid
Payload edited, old signature kept 1700000100 invalid signature
Header says none 1700000100 algorithm not allowed: none

Three details deserve attention in the code. The verifier checks alg before it touches the key, so the token cannot steer the algorithm. It compares the signature in constant time after confirming equal lengths, because timingSafeEqual requires buffers of the same size. And the time is a parameter, which makes the function testable without waiting for real clocks.

Python

import base64, hashlib, hmac, json, time

def b64u(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

def from_b64u(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def compact(obj) -> bytes:
    return json.dumps(obj, separators=(",", ":")).encode()

def sign(payload, secret: bytes) -> str:
    signing_input = b64u(compact({"alg": "HS256", "typ": "JWT"})) + "." + b64u(compact(payload))
    sig = hmac.new(secret, signing_input.encode(), hashlib.sha256).digest()
    return signing_input + "." + b64u(sig)

def verify(token, secret, issuer, audience, now=None, leeway=0):
    now = int(time.time()) if now is None else now
    h, p, s = token.split(".")
    header = json.loads(from_b64u(h))
    if header.get("alg") != "HS256":
        raise ValueError("algorithm not allowed: %s" % header.get("alg"))
    expected = hmac.new(secret, (h + "." + p).encode(), hashlib.sha256).digest()
    if not hmac.compare_digest(expected, from_b64u(s)):
        raise ValueError("invalid signature")
    claims = json.loads(from_b64u(p))
    if now > claims["exp"] + leeway:
        raise ValueError("token expired")
    if claims.get("iss") != issuer:
        raise ValueError("issuer mismatch")
    aud = claims.get("aud")
    if audience not in (aud if isinstance(aud, list) else [aud]):
        raise ValueError("audience mismatch")
    return claims

SECRET = b"demo-secret-do-not-use-in-production"
payload = {"iss": "https://auth.example.test", "sub": "user-1001", "aud": "demo-api",
           "iat": 1700000000, "nbf": 1700000000, "exp": 1700000900, "jti": "demo-0001", "role": "reader"}
token = sign(payload, SECRET)
print(token)
print(verify(token, SECRET, "https://auth.example.test", "demo-api", now=1700000100))

The Python program produced a token identical to the Node.js one, character for character. That is a useful sanity check in any language pair: if two independent implementations agree on the bytes, your encoding, key handling and compact JSON serialisation are right. The key detail on the Python side is separators=(",", ":"). Python's default json.dumps inserts spaces after commas and colons, which would change the Base64url text and therefore the signature. The same point comes up in our guide to JSON minification.

Notice that the two implementations also depend on property order. Both wrote alg before typ and kept the claims in the same sequence. For verification, that does not matter, because the verifier hashes the received text, not a re-serialised copy. For comparing tokens made by two implementations, as we did here, the serialisation must match exactly.

Shell with openssl

For a quick independent check, the signature can be recomputed from the shell. Given $h and $p holding the first two parts:

printf '%s' "$h.$p" \
  | openssl dgst -sha256 -hmac "demo-secret-do-not-use-in-production" -binary \
  | openssl base64 -A | tr '+/' '-_' | tr -d '='

The output was SwxS3FjpkNshH0q9B4gina4-v_bARjfirmBzjmyRZ80, the same as the third part of the token. Use printf '%s' and not echo, so that no newline slips into the signing input. This is handy for cross-checking a library, but it is not a verifier: it does not enforce the allow-list or the claim checks.

Verifying asymmetric tokens

For RS256 and ES256, the verifier needs only the public key, and the code shape is the same: parse, check alg, find the key by kid, verify the signature, then check the claims. In Node.js the primitive is crypto.verify("sha256", data, publicKey, signature). We confirmed that an RSA key pair generated in the sandbox signed and verified a test string. For production use a maintained library with a JWKS helper, because the key set caching, kid selection and format conversions are easy to get subtly wrong.

Performance, security and accessibility considerations

Performance

HMAC verification is fast, and in typical services the cost of verifying a token is small compared with network and database latency. RSA verification is cheaper than RSA signing, and ECDSA has the opposite balance, with signing cheaper than verifying, so choose based on where your load falls. Rather than quote generic benchmarks, measure on your own hardware with your own token sizes, as the relative speeds differ by library and platform.

More significant than CPU is size. A JWT travels with every request, and, unlike a session ID, its size depends on the claims you include and the algorithm you choose. A token of 288 characters, like our HS256 example, is modest. An RS256 token with a long claims set can run to a kilobyte or more, and cookies, headers and URLs all have limits. Keep claims lean, shorten claim names only if profiling shows it matters, and avoid duplicating data the server already knows. Remember too that compressing request headers is limited, and that response compression does not apply to tokens in request headers, as our JSON payload size guide discusses for response bodies.

Cache the JWKS, and reuse the parsed key objects, instead of re-importing a key for every request.

Security

The security checklist is covered above, but a few cross-cutting points deserve emphasis.

  • Use TLS everywhere. A bearer token is like cash: whoever holds it can spend it. Transport encryption keeps it from being read in transit.
  • Redact tokens in logs. Tokens in logs, error reports and analytics are credentials in the wrong place. Log the jti or a truncated hash instead.
  • Limit input size before parsing a token, and cap JSON nesting depth in the payload, so that an oversized or deeply nested token cannot exhaust memory.
  • Keep libraries current. JWT libraries have had serious vulnerabilities, many involving the algorithm and key handling issues described here. Update dependencies, and pin and test the configuration that rejects none and enforces your algorithm allow-list.
  • Separate environments. Use different keys and issuers for development, staging and production, so that a leaked test token is useless in production.
  • Plan for compromise. Know how you would rotate a signing key in an emergency, and how long it takes for every verifier to stop trusting the old key.

Accessibility and usability

JWTs are a backend mechanism, but their lifetimes shape the user experience, and that includes users with disabilities.

  • Session expiry. Sudden logouts can be harmful for people who need more time to complete forms, and accessibility guidelines address time limits. Warn users before a session ends and let them extend it, which a silent refresh flow can do without losing their work.
  • Preserve input. If a request fails because a token expired, keep what the user typed, and let them sign in again and resume.
  • Clear error messages. Show plain-language text such as "Your session has ended, please sign in again", announced to assistive technology through an appropriate live region, and not a raw 401 or token error.
  • Do not display tokens. If a developer console or a support screen shows tokens, mask them by default.

Troubleshooting: symptom, cause, fix

Authentication errors are frustrating because the response is deliberately vague. Work from symptom to cause with the decoder and validator open.

Symptom: "invalid signature"

Cause. The verifier's key or algorithm differs from the signer's. Common reasons: wrong secret for the environment, a rotated key where the verifier still holds the old one, an HS256 secret that was Base64-decoded on one side and used as raw text on the other, a public key pasted with altered line breaks, or a token modified in transit.
Fix. Use the JWT Validator with the exact key your service uses. Check kid and compare it with the key set. Confirm both sides treat the secret as the same bytes. For ES256, check that both sides use the same signature format.

Symptom: "token expired"

Cause. The current time is later than exp. Either the token's lifetime is short and the client did not refresh in time, the client reuses an old stored token, or clocks are skewed.
Fix. Decode the token and read exp and iat. The difference tells you the intended lifetime. Add a refresh step before expiry, handle a 401 by refreshing once and retrying, and synchronise clocks with NTP. If a few seconds of skew are the problem, add a small leeway.

Symptom: "token not yet valid" or nbf errors

Cause. The issuer's clock is ahead of the verifier's, or the token was deliberately issued for a future time.
Fix. Check both clocks. Allow a small leeway, and fix the time source on the drifting machine.

Symptom: "audience mismatch" or "invalid audience"

Cause. The token's aud does not contain the identifier your service expects. A token minted for another API, a typo in a configured audience, or a trailing slash that makes two otherwise identical URLs different strings.
Fix. Decode and compare aud with your configured value, character by character. Fix the client's token request so it asks for the right audience, or fix your service's configuration. Do not remove the check to make the error go away.

Symptom: "issuer mismatch" or "invalid issuer"

Cause. The iss value differs from the expected one, for example because of a development versus production provider, a different tenant, or trailing-slash differences.
Fix. Compare strings exactly, and configure the verifier with the issuer value your provider publishes.

Symptom: "algorithm not allowed" or "unsupported algorithm"

Cause. The token's alg is not on your allow-list, which is often correct behaviour. It may be an RS256 token sent to an HS256-only service, or a forged or unsigned token.
Fix. Decode the header and compare alg with your configuration. If your issuer legitimately changed algorithms, update the verifier deliberately. If you did not expect the change, treat it as suspicious and investigate.

Symptom: "jwt malformed" or "invalid token"

Cause. The string does not have three Base64url parts. Typical culprits: a Bearer prefix left in, surrounding quotes, whitespace or a line break added by copy-paste or an email client, a truncated token cut by a size limit, or a standard Base64 string with + and / characters.
Fix. Strip the prefix and whitespace, count the dots, and compare the length at both ends. If the token is truncated, check header and cookie size limits.

Symptom: the token works in the decoder but the API still returns 401

Cause. The decoder shows content without checking trust. The signature, expiry, audience or issuer could still be wrong, or the API may need a scope or role that the token lacks.
Fix. Run the full validation sequence from the checklist, then compare the token's scope or role claims with the endpoint's requirements.

Symptom: users are logged out at random

Cause. Access tokens expire and the refresh flow fails, refresh token rotation is racing between two tabs that both use the same old refresh token, or the server's signing key changed after a restart because it was generated in memory.
Fix. Serialise refresh requests in the client so only one runs at a time, and persist signing keys outside the process. Check server logs for the specific rejection reason.

Symptom: a role change does not take effect

Cause. The old access token still carries the old claims until it expires.
Fix. Shorten the access-token lifetime, force a refresh after sensitive changes, or check a "permissions changed at" marker against iat for sensitive operations.

Symptom: the signature fails between two languages

Cause. Padding. Some libraries emit or expect = padding, or use standard Base64 characters. In Python, forgetting to restore padding before decoding raises an error. In other cases, the JSON was reserialised before hashing.
Fix. Use Base64url without padding for output, add padding back before decoding, and hash the original received text. Cross-check by recomputing the signature with openssl, as shown earlier.

Best practices and checklist

  • Prefer short-lived access tokens, with minutes as a starting point, and combine them with rotating refresh tokens that the server can revoke.
  • Pin the algorithm. The verifier decides the allowed algorithm, one per key, and always rejects none.
  • Use strong keys. Random HS256 secrets of at least 32 bytes, or asymmetric keys when verifiers should not be able to sign.
  • Validate everything: signature, exp, nbf, iss and aud, plus your own rules for purpose and scope.
  • Use constant-time comparison for signatures and secrets.
  • Keep payloads small and non-sensitive. Never include secrets or private personal data.
  • Store browser tokens carefully. Prefer HttpOnly, Secure, SameSite cookies for browser login, and add CSRF protection when you do.
  • Rotate keys with kid and a JWKS, and never follow key locations supplied in a token header.
  • Plan revocation with jti, a per-user cutoff or server-side refresh records.
  • Keep clocks in sync and use a small leeway.
  • Never send tokens in URLs, and redact them from logs.
  • Use maintained libraries, update them, and add tests for expired, tampered, wrong-audience and none tokens.
  • Keep separate keys per environment.
  • Document your token contract: which claims exist, what they mean, how long tokens live and who the audiences are.

A pre-release checklist for an API that accepts JWTs:

  1. Does the verifier configuration list its allowed algorithms explicitly?
  2. Is none rejected, and is there a test for it?
  3. Are exp, nbf, iss and aud all enforced?
  4. Is the key chosen from configuration or a trusted JWKS, not from the token?
  5. Is the secret or private key stored in a secret manager and rotated on a schedule?
  6. Is there a documented way to revoke access in an emergency?
  7. Are tokens kept out of URLs and logs?
  8. Are the access-token lifetime and refresh strategy documented and tested?
  9. Do tests cover tampered, expired, wrong-audience and wrong-algorithm tokens?

Comparison: ways to carry identity and authorisation

Option Revocation Verification Contents visible to client Best for
Server session with opaque ID Immediate Store lookup No Single web app with one backend
JWT, HS256 By expiry or denylist Shared secret Yes Small, trusted set of services
JWT, RS256 or ES256 By expiry or denylist Public key Yes Identity providers, many verifiers
JWE (encrypted JWT) By expiry or denylist Decrypt with key No Rare cases needing confidential claims
Opaque access token with introspection Immediate Call to the issuer No When central control outweighs latency
API keys Manual rotation or delete Lookup Not applicable Server-to-server access for simple integrations
Signed cookies By expiry or server rule Shared secret Often yes Lightweight state in one framework

No row wins everywhere. The comparison shows the real axis of choice: how much you value local verification against how much you value immediate control.

FAQ

Is a JWT encrypted?
No, a standard JWT is signed, not encrypted. The header and payload are Base64url-encoded text that anyone can decode. An encrypted token is a separate format called JWE, which has five parts. Never place secrets in a normal JWT.

What is the difference between decoding and verifying a JWT?
Decoding converts the first two parts back to JSON and needs no key. Verifying checks the signature with the right key and algorithm, and then checks claims such as expiry, issuer and audience. Only a verified token can be trusted.

Can I trust a JWT that decodes correctly?
No. Anyone can create a token that decodes. Trust comes from a valid signature made with a key you control or trust, together with passing claim checks.

HS256 or RS256: which should I use?
Use HS256 when a single service or a small trusted group creates and verifies tokens and can keep the secret safe. Use RS256 or ES256 when many services or untrusted parties need to verify tokens without being able to create them. Whichever you choose, pin it in the verifier.

Where should I store a JWT in a browser?
An HttpOnly, Secure, SameSite cookie is generally safer against script theft than localStorage, though it needs CSRF protection. Storing tokens in web storage exposes them to any script on the page. Weigh both risks for your application.

How long should a JWT live?
As short as your user experience allows. Many designs use minutes for access tokens and a rotating refresh token for continuity. There is no universal number, because the right value depends on how much damage a stolen token could do and how quickly you can revoke it.

How do I revoke a JWT?
Stateless tokens cannot be revoked by themselves. Combine short lifetimes with a denylist keyed on jti, a per-user "issued before" cutoff compared with iat, or server-side refresh tokens that you can delete.

What is the none algorithm, and is it safe?
It describes an unsigned token. It is only suitable where another layer protects integrity. A service that authenticates callers with tokens must reject it, and must choose algorithms from its own configuration.

What is the kid header for?
It names which key signed the token, so a verifier can pick the right key from a set. It enables key rotation without downtime. Treat it as an untrusted hint, and only search among keys you already trust.

Why does my token start with eyJ?
Because the header is a JSON object starting with {", and the Base64url form of those characters begins with eyJ. It is a quick visual clue that you are looking at a JWT part.

What does exp use for time?
A number of seconds since 1 January 1970 UTC, not milliseconds. Passing milliseconds by mistake produces a date far in the future, which makes the token seem valid almost forever.

Can a JWT contain multiple audiences?
Yes. aud can be an array of strings. A recipient checks that its own identifier is one of the entries.

Is it safe to paste a token into an online decoder?
Only if the tool runs locally in your browser, as the DevUtilX tools do, and even then you should prefer test tokens. A live token is a credential. Treat it like a password until it expires, and revoke it if you pasted it somewhere untrusted.

Should I verify JWTs in the browser?
You can check structure and expiry for user-interface purposes, but access control must happen on the server. The browser is controlled by the user, so its decisions cannot enforce security.

What is the difference between an ID token and an access token?
An ID token tells a client who the user is and is meant for the client. An access token grants access to an API and is meant for that API. Do not use one in place of the other.

Conclusion

A JWT is a compact, signed envelope: three Base64url parts that let a service carry claims and prove, with a signature, that a trusted party issued them. It is not a vault. The payload is readable by everyone, decoding proves nothing, and every protection you want has to come from a verifier that pins the algorithm, chooses the key itself and checks exp, nbf, iss and aud. Keep tokens short-lived, plan for revocation and key rotation, store them carefully, and use sessions where sessions are the simpler tool.

To practise, open the JWT Decoder to inspect a token's header and claims, then use the JWT Validator to check a signature, all locally in your browser. To go further, read our guides on JSON formatting, which helps when you debug the JSON around a token, and on JSON minification and payload size, which explains why every byte in a header or body matters.

Further reading

Try the tools

Further reading

Related articles