JWT anatomy: header, payload, signature, base64url, and why decoding is not verifying
Three dots: the structure of a JSON Web Token
A JSON Web Token is a string of exactly three base64url-encoded values joined by dots: base64url(header).base64url(payload).base64url(signature). RFC 7519, published in May 2015, defines the format. Every JWT you encounter — whether it arrives in an Authorization header, a cookie, or a URL query parameter — follows this structure. The three parts can be decoded independently, and the signature part is what ties them together with cryptographic guarantees.
The format was designed to be compact and URL-safe. Before JWT, API authentication often relied on opaque session identifiers stored server-side, requiring a database lookup on every request. JWTs embed the claims directly in the token so a stateless server can verify them without a lookup. This convenience comes with responsibilities: unlike a session token that the server can instantly revoke, a signed JWT remains valid until its exp claim expires unless the server maintains a denylist.
Base64url encoding: a URL-safe variant of base64
Standard base64 encodes binary data using 64 characters: A–Z, a–z, 0–9, +, and /, with = padding. Two of those characters (+ and /) are significant in URLs and HTTP query strings, requiring percent-encoding if a base64 string appears in a URL. Base64url, defined in RFC 4648 Section 5, solves this by substituting + with - and / with _, and omitting the trailing = padding entirely. The result is safe to use in URLs, HTTP headers, and cookies without further encoding.
The inflation ratio is the same as standard base64: every 3 bytes of input become 4 base64url characters — a factor of 4/3, approximately 33% expansion. A typical JWT header {"alg":"HS256","typ":"JWT"} is 26 bytes of UTF-8, encoding to 35 base64url characters. A modest payload with five claims might be 80 bytes, encoding to 108 characters. Because the encoding is reversible without any key, anyone who obtains a JWT can read its header and payload by decoding — base64url is encoding, not encryption.
The header: algorithm selection and token type
The header is a JSON object that tells the receiving party how to process the token. A minimal header contains two fields: alg (the signing algorithm) and typ (always "JWT" for standard tokens). RFC 7518 defines the algorithm identifiers. The most common are: HS256 — HMAC-SHA256, a symmetric algorithm where the same secret is used to both sign and verify; RS256 — RSA-PKCS1v1.5 with SHA-256, asymmetric, where a private key signs and a corresponding public key verifies; ES256 — ECDSA with P-256 and SHA-256, also asymmetric but producing shorter signatures than RSA.
The choice between symmetric and asymmetric algorithms has real architectural consequences. With HS256, every service that needs to verify tokens must possess the shared secret — leaking that secret to one service leaks it to all. With RS256 or ES256, the issuer keeps the private key private and publishes only the public key (often via a JWKS endpoint), so verifying services never handle signing material. For multi-service or third-party integrations, asymmetric algorithms are preferable. The kid (key ID) header parameter, also defined in RFC 7519, allows the issuer to rotate keys and signal which key was used to sign a given token.
The payload: registered claims and what they mean
The payload is a JSON object containing claims — statements about an entity, typically the authenticated user, plus metadata about the token itself. RFC 7519 Section 4.1 defines seven registered claim names whose semantics are standardised: iss (issuer, the principal that issued the token), sub (subject, the principal the token concerns — often a user ID), aud (audience, the recipients for whom the token is intended), exp (expiration time, a NumericDate after which the token must not be accepted), nbf (not before, a NumericDate before which the token must not be accepted), iat (issued at, the NumericDate when the token was issued), and jti (JWT ID, a unique identifier useful for preventing replay attacks).
NumericDate, as defined in RFC 7519, is the number of seconds since 1970-01-01T00:00:00Z UTC (the Unix epoch), not milliseconds. This is a common source of bugs when developers familiar with JavaScript's Date.now() — which returns milliseconds — write token validation code without converting. Custom claims can be added freely alongside registered ones. One important default to remember: the payload of a standard JWT (alg ≠ none, typ: JWT) is signed but not encrypted. Any party that intercepts the token can read all payload claims. If the payload contains sensitive data, the token should be encrypted (using JWE, RFC 7516) or transmitted only over TLS with restricted logging.
The signature: what it protects — and the alg:none pitfall
The signature is computed over the signing input, which is base64url(header) + "." + base64url(payload). For HS256, the signature is HMAC-SHA256(secret, signing_input), then base64url-encoded. For RS256, it is the RSA-PKCS1v1.5-SHA256 signature of the signing input, base64url-encoded. Because the signing input includes both the header and the payload, any modification to either part — even changing a single character — invalidates the signature. A server that verifies the signature correctly can be certain the token was not tampered with after issuance.
In 2015, a well-documented class of vulnerabilities was found in multiple JWT libraries: the alg:none attack. RFC 7519 technically permits "alg":"none" to indicate an unsecured JWT with no signature. The flaw was that some libraries read the alg field from the (unverified) header and used it to select the verification path — if an attacker changed the header to "alg":"none" and removed the signature, the library would declare the token valid without checking any signature at all. CVE-2015-9235 documents this in the popular node-jsonwebtoken library. The fix is straightforward: the verifying party must specify the expected algorithm explicitly and reject any token whose header names a different one. Never rely on the token's own alg claim to decide how to verify it.
Decoding vs verifying: why the difference is critical
Decoding a JWT means base64url-decoding the three parts to recover the JSON header, payload, and raw signature bytes. No key is required. Anyone in possession of a JWT string can decode it and read the claims. This is by design — JWTs are not intended to be opaque containers. A developer debugging an authentication problem, an operations engineer reading an access log, a security researcher auditing a system: all can decode any JWT they have access to. This is precisely why JWT decoder tools are useful.
Verifying a JWT means checking that the signature was produced by the correct key over the exact header and payload bytes. A successful verification proves two things: the token was created by the holder of the signing key (authenticity), and neither the header nor the payload has been altered since signing (integrity). Verification requires the key — the shared secret for HMAC algorithms, the public key for RSA/ECDSA.
The critical mistake is treating a decoded JWT as trustworthy without verification. A client that reads claims from a JWT stored in localStorage and acts on them without a server-side signature check is trusting data that any user can freely fabricate. An attacker can craft {"sub":"admin","role":"superuser"}, base64url-encode it with any arbitrary header, and attach a fake or empty signature — it will decode just fine. Authorization decisions must always be made after cryptographic verification on the server side. A JWT decoder tool is the right instrument for inspection and debugging; it is never a substitute for server-side verification in a running application.