A JSON Web Token (JWT, pronounced "jot") is a compact way to package a set of claims, things like who a user is, what they're allowed to do, and when the token expires, into a single string that can travel in an HTTP header, a URL, or a cookie. You'll see them constantly in modern authentication. Log into almost any API-driven app and there's a good chance a JWT is riding along with every request. They show up in OAuth 2.0 access tokens, OpenID Connect ID tokens, Firebase auth sessions, and countless internal service-to-service calls, because they solve a specific, narrow problem well: proving identity and permissions without a round-trip to a database.
This guide covers what's actually inside a JWT, how the three parts fit together, and the difference between the two dominant signing algorithms (picking the wrong one has bitten real production systems). It also gets into the mistakes that show up over and over in code review, how JWTs stack up against old-fashioned session cookies, and the practical mechanics of decoding a JWT yourself, whether by hand or with a tool.
The three parts
A JWT is three Base64url-encoded segments joined by dots: header.payload.signature. The header names the signing algorithm and token type. The payload holds the actual claims. The signature is a cryptographic hash of the header and payload, computed with a secret or private key the issuing server holds.
Base64url encoding is not encryption. It's the same kind of reversible text encoding used in data URIs, just with a URL-safe alphabet (- and _ instead of + and /, and padding stripped). Anyone who has the token can decode the header and payload instantly, without needing the signing key at all. That's a feature, not a bug: it means a resource server can read a token's claims without calling back to the auth server, which is most of the point of using JWTs in the first place.
Most JWTs, in practice, are small. A token with a handful of standard claims and an HS256 signature typically lands somewhere in the 150–300 byte range once decoded, maybe 200–400 bytes as the encoded string. But it's easy to blow past that. The moment you embed a roles array, a tenant ID, or a permissions list in the payload, tokens routinely grow past 1KB, and that matters practically: some reverse proxies and load balancers cap total header size around 8KB by default. A bloated JWT sitting in an Authorization header alongside a stack of cookies and other headers can push a request over that limit, producing a confusing "431 Request Header Fields Too Large" that has nothing obviously to do with the token itself.
Standard claims: more than sub, iat, and exp
The JWT spec (RFC 7519) defines a small set of "registered" claim names that most libraries recognize and can automatically validate. Knowing them makes it much easier to read a decoded payload and spot what's actually being asserted:
sub(subject): who the token is about, usually a user or account ID.iss(issuer): who created and signed the token. A resource server should check this matches the auth server it trusts, not just any issuer.aud(audience): who the token is intended for. If a token issued for Service A is presented to Service B and B doesn't checkaud, B may accept a token that was never meant for it.exp(expiration time): a Unix timestamp after which the token must be rejected.nbf(not before): a Unix timestamp before which the token isn't valid yet, useful for tokens issued in advance of when they should take effect.iat(issued at): when the token was created, also usable to compute age independent ofexp.jti(JWT ID): a unique identifier for the token itself, useful for logging, tracing, or maintaining a denylist of revoked tokens.
Anything beyond these is a "private" or "public" claim defined by whoever issues the tokens, things like role, email, permissions, or app-specific fields. There's no schema enforcement here, which is exactly why signature verification matters. Nothing stops an attacker from decoding a token, editing the payload to add "role":"admin", and re-encoding it. Without a valid signature check, that forged token looks identical to a real one.
Decoding vs. verifying — the part everyone gets wrong
Decoding a JWT just reveals what it claims. Verifying a JWT checks the signature against the issuer's key to confirm those claims haven't been tampered with. These are completely different operations, and conflating them is a real security bug: if your server trusts a JWT's payload without verifying the signature first, anyone can hand-craft a token claiming to be an admin and your server will believe it.
A JWT decoder (including the one below) intentionally does not verify signatures. It has no access to the issuer's secret key, and showing an unverified "valid" badge would be actively misleading. Signature verification always has to happen server-side, where the key actually lives. A correct server-side check does three things, in order: confirms the signature matches (using the correct key and algorithm), confirms exp hasn't passed and nbf has, and confirms iss/aud match what's expected. Skip any one of those three and forged or misdirected tokens can still slip through, even if the other two pass.
How to decode a JWT
Because the header and payload are just Base64url-encoded JSON, decoding a JWT doesn't require any cryptography. It's a text transformation, not a security operation. Here's the process, whether you're doing it by hand or with a tool:
- Split the token on its two dots into three strings: header, payload, signature.
- Base64url-decode the header. This gives you a small JSON object, typically just
{"alg":"HS256","typ":"JWT"}or similar, naming the signing algorithm. - Base64url-decode the payload the same way. This is the JSON object with all the claims:
sub,exp, and whatever custom fields the issuer added. - Leave the signature alone. It's not decodable into anything meaningful on its own, just a binary HMAC or RSA/ECDSA signature; Base64url-decoding it gives you raw bytes, not information you can read.
You can do this manually in a browser console with JSON.parse(atob(token.split('.')[0].replace(/-/g,'+').replace(/_/g,'/'))) for the header (and the same for the payload). The character-replacement step for URL-safe Base64 trips people up constantly, since standard atob chokes on - and _. That's the whole reason dedicated decoders exist. Not because decoding is hard, but because getting the encoding variant right by hand is fiddly and easy to get subtly wrong (missing padding is the other common gotcha).
GlaeKit's JWT Decoder handles the split, decode, and JSON formatting in one step, entirely in your browser. The token never leaves your machine, and nothing is sent to a server, since decoding doesn't require the signing key anyway.
Signing algorithms: HS256 vs. RS256
The header's alg field determines how the signature is computed. The two you'll run into constantly are HS256 and RS256, and they're not interchangeable; mixing them up in your verification logic is a well-documented vulnerability class.
HS256 (HMAC-SHA256) is symmetric: the same secret key both signs and verifies the token. It's simple and fast, but every service that needs to verify tokens also needs the signing secret, which means every one of those services is a place that secret could leak from. It works well for a single backend that issues and checks its own tokens.
RS256 (RSA-SHA256) is asymmetric: a private key signs, a separate public key verifies. This is the better fit when multiple services need to verify tokens but shouldn't be able to issue new ones. The public key can be distributed freely (it's often published at a /.well-known/jwks.json endpoint), while only the auth server holds the private key.
The dangerous mistake is a class of bug often called "alg confusion." Some early JWT libraries, if not configured to pin an expected algorithm, would trust the alg field inside the token itself to decide how to verify it. An attacker could take a legitimately RS256-signed token, strip the signature, change alg to HS256, and then sign the new token using the RS256 public key (which is, by design, publicly known) as if it were an HMAC secret. A vulnerable server would read alg: HS256 from the header, verify using the public key as the HMAC secret, and accept a forged token. A close cousin is the "alg: none" vulnerability, where the JWT spec technically allows an unsecured token with no signature at all, and some libraries historically accepted this if the server didn't explicitly reject it. Both bugs trace back to the same root cause: trusting the token to declare its own algorithm instead of the server enforcing an expected one. Modern JWT libraries default to rejecting none and require you to pass an explicit allow-list of algorithms. Still, it's exactly the kind of thing worth double-checking in an older codebase.
Common pitfalls
Beyond algorithm confusion, a handful of mistakes account for most real-world JWT security issues:
- Storing JWTs in localStorage. It's convenient, and it's what a lot of tutorials show. But localStorage is readable by any JavaScript running on the page, including an injected script from a cross-site scripting (XSS) bug anywhere else in your app. An httpOnly cookie, by contrast, is invisible to JavaScript entirely, which removes that whole attack path (though it opens up CSRF concerns that need their own mitigation, like a SameSite attribute or a CSRF token).
- Not checking expiry server-side. A frontend hiding a "log in" screen based on a decoded
expis a UX nicety, not a security control. If the API itself doesn't independently reject expired tokens, the expiry claim is decorative. - Secret key leakage. An HS256 secret checked into a public repo, hardcoded in client-side code, or reused across environments (the same secret in staging and production is more common than it should be) turns your entire signature scheme into theater. Anyone with the secret can mint arbitrary tokens.
- No revocation strategy. Because a JWT is self-contained, there's no built-in way to invalidate one before it expires. Deleting a user's account doesn't retroactively kill their still-valid tokens. Systems that need instant revocation usually keep a short-lived access token plus a server-tracked refresh token (or a denylist keyed on
jti), so at least the damage window is small. - Trusting unverified claims for authorization decisions. Decoding a token to read a
roleclaim without having first verified the signature is the single most common flaw in code review. It's easy to write, easy to miss, and it gives an attacker exactly the access they asked for in the payload they wrote themselves.
Why expiry matters
The exp claim is a Unix timestamp marking when the token stops being valid. A correctly implemented server rejects an expired token even if its signature checks out, which limits how long a stolen or leaked token stays useful. Short-lived JWTs (minutes to hours) paired with a separate longer-lived refresh token is the standard pattern for balancing security against not forcing users to log in constantly. A refresh token is typically opaque (not a JWT at all, just a random string) and stored server-side, so it can be revoked directly. The access JWT stays short-lived precisely so a compromise of it is self-limiting.
JWTs vs. session cookies
A traditional server-side session works differently. The server generates a random session ID, stores the actual session data (user ID, roles, whatever) in a database or in-memory store keyed by that ID, and hands the client only the ID, usually in a cookie. Every request, the server looks up the ID against its store to see who the user is.
The tradeoffs run close to inverse:
- Scaling. A session needs a shared store (Redis, a database) reachable by every server handling requests. A JWT needs nothing shared: any server with the public key or secret can verify it independently, which is why JWTs are common in stateless, horizontally-scaled or multi-service architectures.
- Revocation. A session can be deleted server-side and takes effect on the very next request. A JWT is valid until it expires, full stop, unless you build extra infrastructure (a denylist, short expiry plus refresh tokens) to work around that.
- Payload size on the wire. A session cookie is small (just an ID). A JWT carries its claims with it on every request, which is more data over the wire but saves a database round-trip on the server.
- Where the data lives. Session data stays server-side and is never exposed to the client. JWT claims are visible to anyone holding the token, so never put anything genuinely secret (passwords, raw payment details) in a JWT payload; "signed" doesn't mean "hidden."
Neither approach is universally better. A lot of production systems on the classic "monolith with a database" architecture are perfectly well served by sessions, and JWTs earn their complexity mainly once you're dealing with multiple independent services, mobile clients, or third-party API access where a shared session store isn't practical.
Frequently asked questions
Is a JWT encrypted?
No, not by default. A standard JWT is signed, not encrypted — the header and payload are readable by anyone. There's an encrypted variant (JWE), but it's far less common than the signed JWTs (JWS) most APIs use.
Can I trust the contents of a JWT without checking the signature?
No. Anyone can construct a JWT with any payload they like — trusting it without verification means trusting arbitrary user input. Signature verification is what actually establishes that a token came from the claimed issuer.
Why use a JWT instead of a server-side session?
A JWT is self-contained — the server can verify it without a database lookup, which scales better across multiple servers with no shared session store. The tradeoff is that a JWT can't be instantly revoked before it expires, unlike a server-side session that can be deleted on demand.
How do I decode a JWT?
Split the token on its two dots into header, payload, and signature, then Base64url-decode the header and payload — each is just JSON. The signature isn't meant to be human-readable; it's binary output from the signing algorithm. A decoder tool automates the split-and-decode step and formats the JSON, but the underlying operation requires no key and no cryptography.
Is it safe to store a JWT in localStorage?
It's common but riskier than an httpOnly cookie. localStorage is readable by any JavaScript on the page, so a cross-site scripting bug anywhere in your app can exfiltrate the token. An httpOnly cookie is invisible to JavaScript, closing that path, though it needs its own protection against CSRF.