By the DevDash Team · Last updated July 2026
JWT Tokens Explained: How JSON Web Tokens Work
JSON Web Tokens have become the default mechanism for authentication in modern web applications and APIs. If you have ever logged into a single-page app, called a protected API endpoint, or integrated with OAuth, you have used a JWT — whether you realized it or not. This guide breaks down how JWTs actually work, what each part of the token does, the security mistakes that catch developers off guard, and the practical decisions around signing, storage, and token refresh that determine whether your implementation is solid or vulnerable.
What Is a JWT?
A JSON Web Token (JWT, pronounced "jot") is a compact, URL-safe string that represents claims between two parties. It is defined in RFC 7519 and has become the standard way to pass identity and authorization information in stateless systems.
At its core, a JWT is just three Base64Url-encoded strings separated by dots:
header.payload.signature eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
That looks opaque, but each segment is readable once decoded. The critical thing to understand: a JWT is signed, not encrypted. Anyone with the token can decode and read the header and payload. The signature only guarantees the contents have not been tampered with.
The Three Parts of a JWT
1. Header
The header is a JSON object that describes how the token is signed. It typically contains two fields: alg (the signing algorithm, such as HS256 or RS256) and typ (the token type, always "JWT").
{
"alg": "HS256",
"typ": "JWT"
}2. Payload
The payload contains the claims — the actual data the token carries. Claims are statements about the user (or any entity) and additional metadata. There are three types of claims: registered (standard), public, and private.
{
"sub": "user_8742",
"name": "Jane Developer",
"role": "admin",
"iat": 1716239022,
"exp": 1716242622
}Remember: this payload is Base64Url-encoded, not encrypted. Anyone who intercepts the token can decode it and read every claim. Never put passwords, credit card numbers, or other sensitive data in a JWT payload.
3. Signature
The signature is what makes the token trustworthy. It is created by taking the encoded header, the encoded payload, a secret key, and the algorithm specified in the header:
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret )
When the server receives a JWT, it recalculates the signature using the same secret and compares it to the signature in the token. If they match, the token is valid and unmodified. If they differ, the token has been tampered with and should be rejected. You can inspect any token's structure using a client-side JWT decoder.
How JWT Authentication Works
The JWT authentication flow is straightforward. Here is what happens in a typical web application:
1. Client sends credentials (username + password) to /api/login
|
v
2. Server verifies credentials against database
Server creates JWT with user claims (sub, role, exp)
Server signs JWT with secret key
Server returns JWT to client
|
v
3. Client stores JWT (cookie, memory, or localStorage)
Client sends JWT in Authorization header with each request:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
|
v
4. Server receives request
Server extracts JWT from Authorization header
Server validates signature (using same secret key)
Server checks claims (is exp in the future? is iss correct?)
Server grants or denies access based on claimsThe key advantage of this flow is that it is stateless. The server does not need to store session information in a database or in memory. Every request is self-contained — the JWT carries everything the server needs to authenticate and authorize the user. This is why JWTs work so well in distributed systems where requests might hit different servers behind a load balancer.
Standard JWT Claims Explained
RFC 7519 defines seven registered claims. None are mandatory, but they provide a standard vocabulary that libraries and frameworks understand:
iss (Issuer) — Who created the token. Example: "https://auth.example.com" sub (Subject) — Who the token is about. Example: "user_8742" aud (Audience) — Who the token is intended for. Example: "https://api.example.com" exp (Expiration) — When the token expires. Example: 1716242622 (Unix timestamp) nbf (Not Before) — Token is not valid before this. Example: 1716239022 iat (Issued At) — When the token was created. Example: 1716239022 jti (JWT ID) — Unique identifier for the token. Example: "a1b2c3d4-e5f6-7890"
Of these, exp is the most critical for security. Every JWT should have an expiration time. Without it, a stolen token works forever. A common pattern is setting short expiration times (15 minutes to 1 hour) and using refresh tokens to issue new access tokens without requiring the user to log in again.
The aud claim is important in multi-service architectures. A token issued for your user API should not be accepted by your payment service. Checking aud on the receiving end prevents tokens from being used against services they were not intended for.
Signing Algorithms: HS256 vs RS256
HS256 (HMAC with SHA-256)
HS256 is a symmetric algorithm — the same secret key is used to both create and verify the signature. This is simpler to set up and faster to compute, making it a solid choice when the same server (or service) both issues and validates tokens.
// HS256: same secret on both sides
const token = jwt.sign(payload, "my-256-bit-secret", { algorithm: "HS256" });
const decoded = jwt.verify(token, "my-256-bit-secret");The downside: every service that needs to verify tokens must have the secret key. If you share the secret with multiple services, a compromise in any one of them exposes the key for all of them.
RS256 (RSA Signature with SHA-256)
RS256 is an asymmetric algorithm — the token is signed with a private key and verified with the corresponding public key. The auth server keeps the private key; all other services only need the public key to validate tokens.
// RS256: private key signs, public key verifies
const token = jwt.sign(payload, privateKey, { algorithm: "RS256" });
const decoded = jwt.verify(token, publicKey);RS256 is the better choice for distributed systems and microservices. The public key can be shared freely — even published at a JWKS (JSON Web Key Set) endpoint — without any security risk. This is the approach used by OAuth 2.0 providers and OpenID Connect.
When to use which
Use HS256 for simple setups where one server issues and validates tokens. Use RS256 when multiple services need to validate tokens independently, when you use third-party identity providers, or when you want to publish a JWKS endpoint. When in doubt, RS256 is the safer default.
Common JWT Security Pitfalls
JWTs are simple in concept but easy to implement insecurely. These are the mistakes that show up repeatedly in security audits:
Not validating the signature
If your server decodes the JWT payload without verifying the signature, an attacker can modify any claim — change their user ID, escalate their role to admin, extend the expiration — and the server will trust it. Always verify the signature before reading claims. Every JWT library provides a verify() function that does this; use it instead of just decoding.
Accepting the "none" algorithm
The JWT specification includes an alg: "none" option for unsecured tokens. If your server accepts this, an attacker can create a token with no signature at all, and your server will treat it as valid. Always explicitly specify which algorithms your server accepts and reject tokens that use any other algorithm.
// DANGEROUS: accepts any algorithm, including "none"
jwt.verify(token, secret);
// SAFE: explicitly whitelist allowed algorithms
jwt.verify(token, secret, { algorithms: ["HS256"] });Storing sensitive data in the payload
This bears repeating: JWTs are Base64-encoded, not encrypted. Anyone can decode the payload — there is no secret involved in reading it. Put user IDs, roles, and expiration times in the payload. Never put passwords, API keys, personal data (SSN, addresses), or anything you would not want visible in a browser's developer console.
Not checking expiration
A JWT without an exp claim never expires. If the token is stolen, it works forever. Always set a reasonable expiration time and always validate it on the server. Most JWT libraries check exp automatically during verification, but confirm that this is not disabled in your configuration.
Weak signing secrets
If you use HS256, the secret key must be strong enough to resist brute-force attacks. A secret like secret or password123 can be cracked in seconds using tools that specifically target JWT secrets. Use at least a 256-bit random key — generate one with a hash generatoror your language's cryptographic library.
JWT vs Session-Based Authentication
JWT and session-based authentication solve the same problem — keeping a user logged in across requests — but they make fundamentally different tradeoffs.
Session-based authentication
The server creates a session record (in memory, Redis, or a database) and sends the client a session ID stored in a cookie. On each request, the server looks up the session ID to find the user's state. This approach gives you full control: you can revoke a session instantly by deleting the record, see all active sessions, and the cookie size is tiny.
JWT-based authentication
The server encodes all user state directly into the token. No server-side storage is needed. This makes horizontal scaling straightforward — any server behind a load balancer can validate the token independently. But you lose the ability to instantly revoke a token (it remains valid until it expires) and each request carries the full token, which adds bandwidth overhead.
Practical tradeoffs
Sessions JWTs Scaling Needs shared store Stateless, scales easily Revocation Instant (delete) Hard (wait for expiry) Storage Server-side Client-side Size per request Small cookie (~32B) Full token (~800B-2KB) Cross-domain Tricky (CORS) Easy (Authorization header) Mobile/API clients Awkward Natural fit
Neither approach is universally better. Use sessions when you need instant revocation and have a manageable server infrastructure. Use JWTs when you are building APIs consumed by mobile apps, SPAs, or third-party services where statelessness is a requirement. Many production systems use both — JWTs for API authentication and sessions for server-rendered web interfaces.
Where to Store JWTs: localStorage vs Cookies vs Memory
Where you store JWTs in the browser has significant security implications. Each option involves a different risk profile.
localStorage
Storing JWTs in localStorage is the most common approach and the least secure. Any JavaScript running on the page — including third-party scripts and XSS payloads — can read localStorage. A single cross-site scripting vulnerability lets an attacker exfiltrate the token and impersonate the user from a completely different device.
HttpOnly cookies
Storing JWTs in HttpOnly cookies is more secure for web applications. HttpOnly cookies cannot be accessed by JavaScript, which eliminates the XSS token theft vector entirely. Combine with Secure (HTTPS only) and SameSite=Strict or SameSite=Lax flags for CSRF protection.
Set-Cookie: token=eyJhbGciOi...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600
The tradeoff: cookies are automatically sent with every request to the domain, which introduces CSRF risks if you do not use SameSite flags. Also, cookies are limited to same-origin or same-site requests — they do not work well for cross-domain API calls.
In-memory (JavaScript variable)
Storing the token in a JavaScript variable (for example, in application state or a closure) is the most secure option against persistent attacks — the token is lost on page refresh, and no browser API can access it from outside the running script. However, this means the user must re-authenticate on every page load unless you pair it with a refresh token flow using HttpOnly cookies.
Recommendation
For most web applications, store JWTs in HttpOnly cookies with Secure and SameSite flags. For SPAs that need cross-domain API access, use in-memory storage combined with a silent refresh mechanism. Avoid localStorage for production authentication unless you have strong XSS defenses and accept the residual risk.
Refresh Token Patterns
Short-lived access tokens are a security best practice — if a token is stolen, the window of exposure is limited. But short-lived tokens mean users would need to log in every 15 minutes without a refresh mechanism. Refresh tokens solve this.
How refresh tokens work
When the user logs in, the server issues two tokens: a short-lived access token (15 minutes to 1 hour) and a long-lived refresh token (days or weeks). The access token is used for API requests. When it expires, the client sends the refresh token to a dedicated endpoint to get a new access token without requiring the user to enter credentials again.
1. POST /api/login → { accessToken (15min), refreshToken (7d) }
2. GET /api/data → Authorization: Bearer <accessToken>
3. Access token expires
4. POST /api/refresh → { newAccessToken (15min) }
Body: { refreshToken }
5. GET /api/data → Authorization: Bearer <newAccessToken>Refresh token rotation
To limit damage from a stolen refresh token, implement token rotation: each time a refresh token is used, the server issues a new refresh token and invalidates the old one. If an attacker uses a stolen refresh token, the legitimate user's next refresh attempt will fail (because the old token was rotated), signaling a breach. At that point, invalidate all tokens for that user.
Storage split
A common pattern is storing the access token in memory (JavaScript variable) and the refresh token in an HttpOnly cookie. The access token is used for API calls and lost on refresh. When the page loads, a background request to the refresh endpoint (sending the cookie automatically) gets a new access token. This combines the security of in-memory access tokens with the persistence of cookie-based refresh tokens.
Debugging JWT Issues
JWT-related bugs are among the most frustrating to debug because the symptoms are generic — a 401 response does not tell you whether the token is expired, malformed, signed with the wrong key, or missing entirely. Here is a systematic approach:
Inspect the token structure
Start by decoding the token to check whether the header and payload contain what you expect. Use a client-side JWT decoder to inspect tokens without sending them to a third-party server. Check the alg in the header, the exp and iat timestamps in the payload, and verify the sub and aud claims match your expectations.
Common issues
Clock skew: If the issuing server and validating server have different system clocks, tokens can appear expired or not-yet-valid. Most JWT libraries accept a clockTolerance option (typically 30-60 seconds) to handle this.
Wrong key or algorithm: If the server expects RS256 but receives an HS256 token (or vice versa), verification will fail. This commonly happens during environment transitions or when rotating keys.
Token format issues: Ensure the Authorization header includes the Bearer prefix. A missing space or prefix causes many libraries to reject the token before even attempting to verify it.
Encoding problems: JWTs use Base64Url encoding (not standard Base64). The difference is that + is replaced with -, / is replaced with _, and padding = is removed. Manual encoding can introduce subtle bugs.
JWTs in Practice: OAuth 2.0, OpenID Connect, and API Gateways
OAuth 2.0
OAuth 2.0 is an authorization framework — it defines how applications obtain limited access to user accounts on other services. While OAuth 2.0 does not mandate JWTs (access tokens can be opaque strings), many implementations use JWTs as access tokens because they are self-contained. The resource server can validate the token without calling back to the authorization server, reducing latency and coupling.
OpenID Connect
OpenID Connect (OIDC) builds on top of OAuth 2.0 specifically for authentication. OIDC does mandate JWTs — the ID token must be a JWT containing standardized claims about the user (such as sub, email, name). Every major identity provider — Google, Microsoft, Auth0, Okta — uses OIDC, which means JWTs are the tokens you receive when integrating with any of these services.
API gateways
API gateways like Kong, AWS API Gateway, and Nginx commonly handle JWT validation at the edge, before requests reach your application servers. The gateway verifies the signature, checks expiration, and optionally extracts claims to pass as headers to downstream services. This centralizes authentication logic and keeps it out of your application code.
In a typical microservices architecture, the flow looks like this: the client sends a JWT to the API gateway, the gateway validates the token and forwards the request with user context (extracted from claims) to the appropriate backend service. The backend service trusts the gateway's validation and uses the forwarded claims for authorization decisions.
JWKS endpoints
When using RS256, the public key used to verify tokens is published at a JWKS (JSON Web Key Set) endpoint — typically at /.well-known/jwks.json. Verifying services periodically fetch this endpoint to get the current public keys, enabling seamless key rotation. When the auth server rotates keys, it publishes the new key alongside the old one for a transition period, then removes the old key. Services that cache the JWKS pick up the change on their next fetch.
JWT Implementation Checklist
If you are implementing JWT authentication, use this checklist to avoid the most common pitfalls:
[ ] Always verify the signature — never just decode [ ] Whitelist allowed algorithms (reject "none") [ ] Set and validate exp on every token [ ] Use strong secrets (256+ bits) for HS256 [ ] Prefer RS256 for multi-service architectures [ ] Never store sensitive data in the payload [ ] Store tokens in HttpOnly cookies (web) or secure storage (mobile) [ ] Implement refresh token rotation [ ] Set reasonable token lifetimes (15min-1hr for access tokens) [ ] Validate iss, aud, and sub claims on the server [ ] Handle clock skew with a tolerance window [ ] Plan for token revocation (blocklist or short expiry + refresh)
Decode and Inspect JWT Tokens Instantly
Paste any JWT to see its header, payload, and signature breakdown. Client-side processing — your tokens never leave your browser.
Open JWT Decoder