Understanding JWT Tokens: Structure, Security, and Validation
Learn how JSON Web Tokens work, their structure, security considerations, and best practices for implementation and validation.
What Is a JWT, and Why Does It Matter?
JSON Web Token — universally abbreviated as JWT and pronounced "jot" — is a compact, URL-safe format for transmitting claims between two parties. If you have ever logged into a single-page application, called a protected API, or federated identity across microservices, chances are a JWT was doing the heavy lifting behind the scenes. The token's appeal lies in its self-contained nature: a server can verify a user's identity and permissions simply by inspecting the token itself, without hitting a session store or a database. That stateless quality makes JWTs the de facto standard for modern authentication flows built on OAuth 2.0 and OpenID Connect (OIDC).
Yet JWTs are deceptively simple. The barrier to generating one is low — a handful of lines in any language will do — but the barrier to handling them securely is considerably higher. Misconfigured tokens have been at the root of high-profile breaches, from algorithm confusion attacks to tokens that never expire. This article takes you through the internal structure of a JWT, the cryptography that protects it, the most common pitfalls developers stumble into, and the best practices that keep production systems safe.
Anatomy of a JWT
A JWT is a single string composed of three Base64URL-encoded segments separated by dots: header.payload.signature. Each segment serves a distinct purpose, and understanding all three is essential before you write a single line of token-handling code.
The Header
The header is a small JSON object that describes the token itself. Its two most important fields are alg, which names the signing algorithm, and typ, which is almost always set to "JWT". A typical header looks like this:
{
"alg": "RS256",
"typ": "JWT"
}
After being serialized to a UTF-8 string and run through Base64URL encoding (a URL-safe variant of Base64 that strips padding characters and swaps + and / for - and _), the header becomes the first dot-delimited segment of the token. Because the encoding is reversible, anyone who possesses the token can read the header — it is not encrypted, merely encoded.
The Payload
The payload is the most interesting segment. It contains claims — key-value assertions about the subject of the token and metadata about the token itself. The JWT specification (RFC 7519) defines seven registered claims: iss (issuer), sub (subject), aud (audience), exp (expiration time), nbf (not before), iat (issued at), and jti (a unique JWT ID). None of these are mandatory, but exp and iss are strongly recommended for any token that will be used in an authentication flow.
Beyond the registered claims, you are free to add public claims (namespaced to avoid collisions, e.g., "https://example.com/roles") and private claims (custom keys agreed upon by the issuing and consuming parties, such as "user_id" or "tenant").
A practical payload might look like this:
{
"iss": "https://auth.example.com",
"sub": "user-4821",
"aud": "https://api.example.com",
"exp": 1738980000,
"iat": 1738976400,
"roles": ["editor", "viewer"]
}
Like the header, the payload is Base64URL-encoded, not encrypted. This is the single most important thing to internalize about JWTs: never store sensitive data — passwords, social security numbers, credit card details — in the payload, because anyone with access to the token can decode and read it.
The Signature
The signature is what turns an easily forgeable pair of JSON objects into a trustworthy credential. To create it, the signing party concatenates the encoded header and encoded payload with a dot, then applies the algorithm named in the header using a secret or private key:
signature = algorithm(base64UrlEncode(header) + "." + base64UrlEncode(payload), key)
When the receiving party wants to verify the token, it recomputes the signature using the same algorithm and key (or the corresponding public key) and compares the result to the signature segment. A match proves that the header and payload have not been altered since the token was signed.
HMAC vs. RSA: Choosing a Signing Algorithm
The choice of signing algorithm is one of the most consequential decisions in your JWT architecture.
Symmetric algorithms (HS256, HS384, HS512) use a single shared secret for both signing and verification. They are fast and easy to set up in monolithic applications where the same server issues and validates tokens. The drawback is that every service that needs to verify a token must possess the secret, which widens the attack surface.
Asymmetric algorithms (RS256, RS384, RS512 for RSA; ES256, ES384, ES512 for ECDSA) use a keypair: the private key signs, and the public key verifies. This means you can publish your public key — or expose it via a JWKS (JSON Web Key Set) endpoint — so that any downstream service can verify tokens without ever seeing the signing key. Asymmetric signing is the standard choice for microservice architectures, OAuth 2.0 authorization servers, and any scenario where the token issuer and the token consumer are different systems.
In practice, RS256 is the most widely deployed asymmetric algorithm because RSA key generation is straightforward and library support is universal. ES256 (ECDSA with the P-256 curve) produces smaller signatures and is faster to verify, making it an attractive alternative for mobile and IoT workloads where bandwidth is constrained.
Common JWT Vulnerabilities
JWTs are secure by design, but implementations regularly introduce vulnerabilities. Three attack patterns appear with alarming frequency.
The "none" Algorithm Attack
The JWT header includes an alg field, and the specification defines "none" as a valid value, intended for contexts where the token is protected by other means (e.g., a TLS channel). Early JWT libraries accepted the algorithm declared in the header at face value. An attacker could forge a token, set "alg": "none", strip the signature, and the server would happily accept it as valid. The fix is absolute: your verification code must never accept the algorithm from the token itself. Always enforce the expected algorithm on the server side, and reject any token that declares a different one.
Algorithm Confusion
This attack targets systems that support both HMAC and RSA. In RS256, the server verifies tokens using an RSA public key. An attacker obtains the public key (which is, by definition, public), forges a token with "alg": "HS256", and signs it using the public key as the HMAC secret. If the server's verification logic blindly trusts the alg header, it will pass the public key to the HMAC verification function, which will succeed because the attacker used that exact key as the HMAC secret. The defense is the same as for the none attack: hardcode the expected algorithm in your verification configuration.
Expired and Never-Expiring Tokens
A token without an exp claim is valid forever — or at least until the signing key is rotated. If such a token is leaked, the attacker has indefinite access. Always set exp to a short duration: 15 minutes for access tokens is a common starting point. Pair short-lived access tokens with longer-lived refresh tokens to maintain user sessions without issuing access tokens that linger.
Token Storage: Cookies vs. localStorage
Where you store JWTs in a browser application matters as much as how you sign them. The two mainstream options are localStorage and HTTP-only cookies, and neither is without trade-offs.
localStorage is the simpler choice from a developer experience standpoint: you stash the token after login and attach it as a Bearer header on every API request. The risk is that any cross-site scripting (XSS) vulnerability in your application gives the attacker direct access to the token. Because JavaScript can read localStorage freely, a single injected script can exfiltrate every token in the store.
HTTP-only, Secure, SameSite cookies remove the token from JavaScript's reach entirely. The browser attaches the cookie to requests automatically, and the HttpOnly flag prevents scripts from reading it. The SameSite=Strict (or Lax) attribute mitigates cross-site request forgery (CSRF), and the Secure flag ensures the cookie is only sent over HTTPS. The downside is that cookies are sent on every request to the domain, including non-API requests like image loads, which can be wasteful — and CSRF protection still requires careful configuration.
For most web applications, HTTP-only cookies with SameSite protection are the more defensible choice. For native mobile apps that do not run in a browser context, secure platform storage (Keychain on iOS, EncryptedSharedPreferences on Android) is the appropriate alternative.
Refresh Token Patterns
Short-lived access tokens solve the problem of long-exposure credentials, but they create a user-experience problem: nobody wants to re-authenticate every 15 minutes. The solution is a refresh token — a longer-lived, opaque credential that the client presents to the authorization server in exchange for a fresh access token.
Refresh tokens should be stored securely (HTTP-only cookies or platform secure storage), rotated on every use (so a stolen refresh token can only be used once before the server detects the anomaly), and bound to the original client. Many authorization servers implement refresh token rotation with reuse detection: if a refresh token is used twice, the server assumes it was stolen and revokes the entire token family, forcing the user to re-authenticate.
A typical flow looks like this:
- The user authenticates and receives an access token (15-minute expiry) and a refresh token (7-day expiry).
- The client uses the access token for API calls.
- When the access token expires, the client sends the refresh token to the authorization server's token endpoint.
- The server validates the refresh token, issues a new access token and a new refresh token, and invalidates the old refresh token.
- If the old refresh token is ever presented again, the server revokes all tokens in the family.
JWTs in OAuth 2.0 and OpenID Connect
OAuth 2.0 is an authorization framework that delegates user consent to an authorization server, which issues tokens to client applications. JWTs are not required by the OAuth 2.0 spec — access tokens can be opaque strings — but in practice, many implementations use JWTs because they allow resource servers to validate tokens locally without calling the authorization server on every request.
OpenID Connect (OIDC) is an identity layer built on top of OAuth 2.0, and it does mandate JWTs for its ID token. The ID token carries claims about the authentication event — who the user is, when they authenticated, and how — and is always a signed JWT. Resource servers should not use the ID token for API authorization; that is the access token's job. The ID token is intended for the client application, to establish a login session and display user profile information.
When working with OIDC, the authorization server publishes its public keys at a well-known JWKS endpoint (typically /.well-known/jwks.json). Your resource server fetches these keys, caches them, and uses them to verify incoming access tokens. This decouples token issuance from token verification and is the foundation of scalable, stateless authentication in distributed systems.
Validating JWTs: A Checklist
Proper validation is not optional — it is the entire point of the signature. Every token your server accepts should pass through these checks:
- Decode and parse the header and payload. Reject malformed tokens immediately.
- Enforce the expected algorithm. Do not read
algfrom the header to decide how to verify. - Verify the signature using the appropriate key (shared secret for HMAC, public key for RSA/ECDSA).
- Check
expand reject expired tokens. Allow a small clock skew window (30 seconds is common) to accommodate server time drift. - Check
issagainst the expected issuer URL. - Check
audagainst your service's identifier to ensure the token was intended for you. - Check
nbfif present, to ensure the token is already valid.
Use an established, well-maintained library for validation — jsonwebtoken or jose in Node.js, PyJWT in Python, java-jwt or Nimbus JOSE in Java. Rolling your own JWT verification is an invitation for subtle, exploitable bugs.
Inspecting and Building Tokens
During development and debugging, you will frequently need to peek inside a JWT to check its claims, verify its expiration, or confirm which algorithm was used. The JWT Decoder lets you paste any token and instantly see the decoded header, payload, and signature verification status — all processed locally in your browser, so your tokens and secrets never leave your machine.
When prototyping authentication flows or generating test fixtures, the JWT Builder lets you construct tokens with custom claims, choose a signing algorithm, and set expiration and other standard claims. It is especially useful for creating tokens that exercise edge cases — expired tokens for testing rejection logic, tokens with unusual claim sets for integration testing, or tokens signed with different algorithms for verifying your server's algorithm enforcement.
Conclusion
JWTs are the workhorse of modern web authentication, but their simplicity can be deceptive. The token's three-part structure — header, payload, signature — is easy to understand, yet the security surface around signing algorithms, token storage, expiration policy, and validation logic demands careful, deliberate engineering. Use asymmetric signing for distributed systems, keep access tokens short-lived, store them in HTTP-only cookies whenever possible, and never trust the algorithm declared in a token's header. With those principles in place, JWTs become a reliable, scalable foundation for identity and authorization across any architecture.
Related Tools
Related Articles
Try Our Free Tools
200+ browser-based tools for developers and creators. No uploads, complete privacy.
Explore All Tools