JWT decode vs verify: what a readable token does not prove

Updated 5 min read

You paste a token into a decoder, the claims appear, sub looks right and exp is in the future. It is tempting to conclude the token is good. All that happened is that someone read a string; nothing checked that it came from your issuer.

A JWT is three readable parts

A signed JWT is header.payload.signature, each part base64url-encoded and joined with dots. The header and payload are JSON, and base64url is an encoding, not a lock. The most famous example header, {"alg":"HS256","typ":"JWT"}, encodes to eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9, and this is why almost every token starts with eyJ: that is how {" looks in base64. The classic sample payload {"sub":"1234567890","name":"John Doe","iat":1516239022} encodes to eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.

Decoding needs no secret and no public key. That is also why a JWT is the wrong place for anything confidential: a signed token (JWS) is integrity-protected, not encrypted. An encrypted token (JWE) exists, but it has five dot-separated parts, not three, and a decoder for signed tokens cannot read it.

What verification adds

Verification recomputes the signature over header.payload with the key and compares. For HS256 that is an HMAC with a shared secret; for RS256 and ES256 it is a check against the issuer’s public key. Only after that succeeds is anything in the payload evidence of who wrote it. Then the claims still have to be judged:

CheckFails when
signaturethe token was altered or signed with another key
expthe token has expired
nbfthe token is not valid yet
issit was issued by someone you do not trust
audit was issued for a different service

The JWT decoder linked below stops before all of this. It labels itself “decoded, not verified”, never checks the signature, and says so in its status line, because a readable payload proves nothing about trust. The exp verdict it prints tells you about time, not about authenticity.

In code the two operations often sit one letter apart. In the jsonwebtoken library for Node, jwt.decode(token) returns the payload without checking anything, and jwt.verify(token, key, options) is the one you want on a request path. Libraries such as jose and PyJWT only offer verifying calls by default for the same reason.

The alg header is attacker-controlled

The header says which algorithm to use, and the sender writes the header. Two classic failures follow:

  • alg: none. A token with {"alg":"none"} and an empty signature is a legitimate unsecured JWT. A server that accepts whatever the header says accepts it. The tool’s sample token is built this way on purpose, and it warns you that the signature part is empty.
  • Algorithm confusion. A server expecting RS256 holds the issuer’s public key, which is not secret. If a library lets the header choose HS256, an attacker signs a forged token with HMAC using that public key as the secret, and the server verifies it successfully.

The defense is the same for both: pass an explicit list of allowed algorithms to the verify call, for example { algorithms: ['RS256'] }, rather than trusting the header. Treat kid the same way, as an untrusted hint for picking a key from a set you control, never as a file path or a query fragment.

exp, nbf and iat are seconds

These claims are NumericDate values: seconds since the Unix epoch. JavaScript’s Date wants milliseconds, so new Date(payload.exp) lands in January 1970, and Date.now() > payload.exp is always true.

const expired = Date.now() / 1000 >= payload.exp;

The reverse mistake, issuing exp in milliseconds, produces a token that appears to expire in the year 57,000 or so and in practice never does. The decoder shows each time claim as a local date-time with a zone name plus a relative note such as “expired 5 minutes ago”, which makes both mistakes visible. When a token fails seconds after being issued, suspect clock drift between servers and allow a small leeway on exp and nbf; most libraries have an option for it. A token with no exp never expires, and the tool says that explicitly instead of calling it fine.

Chinese claims turn into mojibake

Pasting a payload with "name":"张三" into a quick atob one-liner gives garbage like å¼ ä¸, because atob returns one character per byte and the claim is UTF-8. Two further details break hand-rolled decoding: the alphabet uses - and _ instead of + and /, which atob rejects, and the padding = is stripped. A correct decoder swaps the characters, restores padding and then decodes the bytes as UTF-8:

function decodePart(part) {
  const b64 = part.replace(/-/g, '+').replace(/_/g, '/');
  const bin = atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, '='));
  return new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)));
}

The tool does exactly this, strips a leading Bearer if you copied the whole header, and names the failing part when the input is not a token, for example when it finds two parts instead of three.

A minimal verifying call

With jose, one call covers signature, algorithm, issuer and audience, and the library checks exp and nbf itself:

import { jwtVerify } from 'jose';

const { payload } = await jwtVerify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: 'https://auth.example.com',
  audience: 'my-api',
});

If it throws, the token is untrusted; do not fall back to the decoded payload. A common anti-pattern is reading userId out of a decode call, using it for a database lookup and verifying afterwards, or verifying at a gateway and then re-decoding in each service on the assumption that the gateway is always in front.

When the decoded result is not what you expected

  • “No dot” or “only two parts”: the paste was cut short, or the value is a refresh token or session id that is not a JWT at all. Five parts means a JWE, whose content is encrypted and unreadable here.
  • Header decodes, payload errors: check whether a log system inserted line breaks or an ellipsis. base64url never contains spaces.
  • Decodes fine but the content looks wrong: aud can be a string or an array, so payload.aud === 'my-api' misses the array form, and custom claim names are case-sensitive.
  • Decodes, but verification fails: check alg and kid and make sure you hold the matching key and that it has not been rotated.

Where to paste tokens

A live token is a credential until it expires. Decoding in a tab that makes no request keeps it on your machine, which is how the tool linked here works, but the safer habit still holds: decode tokens from production in a place you trust, redact them before putting them in a ticket, and prefer short-lived ones.

Open the tool: JWT Decoder

Back to guides

More guides