Skip to main content
Developer 8 min read · In-depth 2026-04-13

JWT Authentication: A Complete Guide to JSON Web Tokens for Web Developers

A comprehensive guide to JSON Web Tokens — covering token structure, authentication flows, signature algorithms, common security pitfalls, and production best practices.

1

What Is a JSON Web Token and How Does It Work?

A JSON Web Token (JWT) is an open standard (RFC 7519) for securely transmitting information between parties as a compact, URL-safe string. JWTs are most commonly used for authentication and authorization in web applications — after a user logs in, the server generates a JWT and sends it to the client, which includes the token in subsequent requests to prove the user's identity without needing to send credentials repeatedly.

The key characteristic of a JWT is that it is self-contained. The token itself carries all the information needed to verify the user's identity and permissions — typically including the user ID, roles, expiration time, and issuer. This means the server does not need to query a database or session store on every request to authenticate the user. The token is digitally signed, so the server can verify that it has not been tampered with. This stateless nature makes JWTs particularly well-suited for distributed systems, microservices architectures, and applications that need to scale horizontally without shared session state.

It is critical to understand that a JWT is signed, not encrypted (unless you specifically use JWE — JSON Web Encryption). The standard JWT uses a signature to ensure integrity and authenticity, but the payload is Base64Url-encoded, not encrypted. Anyone who intercepts the token can decode and read its contents. This means you should never store sensitive data like passwords, credit card numbers, or personal identification numbers in a JWT payload. Think of a JWT as a sealed envelope — anyone can read the address on the outside, but the seal (signature) guarantees that the contents have not been altered since the sender sealed it.

JWTs are used in three primary scenarios: authentication (proving who the user is), authorization (determining what the user can access), and information exchange (securely transmitting claims between systems). The most common use case is authentication: after a user provides valid credentials, the authentication server issues a JWT that the client presents with every subsequent API request.

2

JWT Structure: Header, Payload, and Signature

A JWT consists of three parts separated by dots: xxxxx.yyyyy.zzzzz — the header, the payload, and the signature. Each part is Base64Url-encoded. Understanding what each part contains is essential for working with JWTs effectively.

The Header specifies the token type and the signing algorithm. It is a JSON object with two fields: "typ" (typically "JWT") and "alg" (the algorithm used to sign the token, such as "HS256" for HMAC-SHA256 or "RS256" for RSA-SHA256). The header is Base64Url-encoded to form the first part of the token. For example, the header {"alg":"HS256","typ":"JWT"} encodes to eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. This is readable by anyone — it simply tells the verifier how to validate the signature.

The Payload contains the claims — statements about the entity (usually the user) and additional metadata. Claims come in three categories: registered claims (predefined by the JWT specification, such as "iss" for issuer, "sub" for subject, "aud" for audience, "exp" for expiration time, "iat" for issued-at time, and "jti" for a unique token identifier), public claims (defined by the application using the JWT, registered in the IANA JSON Web Token Registry to avoid collisions), and private claims (custom key-value pairs agreed upon between the parties exchanging the token). The payload is also Base64Url-encoded. Keep the payload lean — every additional claim increases the token size, which adds overhead to every HTTP request.

The Signature is the part that ensures the token has not been tampered with. To create the signature, you take the encoded header, the encoded payload, a secret key (for HMAC algorithms) or a private key (for RSA/ECDSA algorithms), and sign the concatenation of the header and payload using the specified algorithm. For example, with HS256: signature = HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret). The resulting signature is appended as the third part of the token. When the server receives the token, it recalculates the signature and compares it to the one in the token. If they match, the token is valid and untampered.

The distinction between symmetric algorithms (HMAC) and asymmetric algorithms (RSA, ECDSA) is important. With HMAC, the same secret is used to sign and verify the token, which means any service that verifies the token also has the ability to create new tokens. With RSA or ECDSA, the token is signed with a private key and verified with a public key — services can verify tokens without being able to create them, which is essential in microservices architectures where you want to restrict token creation to a dedicated authentication service.

3

How JWT Authentication Flows Work in Practice

Understanding the authentication flow is essential for implementing JWT-based security correctly. There are two primary patterns: the traditional server-issued JWT flow and the third-party OAuth 2.0 flow. Both follow a similar lifecycle but differ in how the initial authentication is performed.

Traditional JWT Authentication Flow: Step 1 — The client sends a login request with credentials (username and password) to the authentication endpoint (typically POST /auth/login). Step 2 — The server validates the credentials against the database. If valid, the server generates a JWT containing the user's identity claims (user ID, roles, permissions) and signs it with the secret or private key. Step 3 — The server returns the JWT to the client, typically in the response body. Step 4 — The client stores the token (usually in memory, localStorage, or an HTTP-only cookie) and includes it in the Authorization header of every subsequent API request using the Bearer scheme: Authorization: Bearer eyJhbGci.... Step 5 — For each request, the server extracts the token from the header, verifies the signature, checks the expiration claim, validates the issuer and audience, and then processes the request based on the user's identity and permissions embedded in the token.

OAuth 2.0 / OpenID Connect Flow: In this pattern, a third-party identity provider (such as Google, Auth0, Okta, or Keycloak) handles the authentication. The user is redirected to the identity provider's login page, authenticates there, and is redirected back to your application with an authorization code. Your server exchanges this code for an access token (which is a JWT) and optionally an ID token (also a JWT, containing user profile information). The access token is then used to authorize API requests, while the ID token provides user identity information. This flow offloads the complexity of credential management to a dedicated service, which is generally more secure and provides features like multi-factor authentication, social login, and single sign-on out of the box.

Token Refresh Flow: Access tokens should have short lifetimes (typically 15–60 minutes) to limit the damage if a token is compromised. When the access token expires, the client uses a refresh token — a long-lived, opaque token stored securely — to obtain a new access token from the server without requiring the user to log in again. The refresh token is typically stored in an HTTP-only, Secure, SameSite cookie to protect it from JavaScript access and cross-site request forgery. This two-token pattern provides a good balance between security (short-lived access tokens) and user experience (seamless token renewal).

4

Common JWT Security Pitfalls and How to Avoid Them

JWTs are powerful but easy to get wrong. Several well-documented security vulnerabilities have affected JWT implementations over the years. Understanding these pitfalls is essential for building secure authentication systems.

Pitfall 1: Accepting the "none" algorithm. The JWT specification includes a "none" algorithm that indicates the token is unsigned. If your verification code does not explicitly reject tokens with "alg":"none", an attacker can modify the payload (for example, changing the user ID to an admin account), set the algorithm to "none", and remove the signature entirely. The server would accept the forged token as valid. Always explicitly whitelist the expected signing algorithms in your verification code and reject any token that uses an unexpected algorithm.

Pitfall 2: Algorithm confusion attacks. Some JWT libraries allow the developer to specify the expected algorithm, while others infer it from the token header. An algorithm confusion attack exploits this by taking an RSA-signed token, changing the header to specify HMAC ("alg":"HS256"), and signing it with the RSA public key (which is publicly available) as the HMAC secret. If the verifier uses the public key as the HMAC secret based on the header's algorithm claim, the signature will validate. Always specify the expected algorithm on the server side and never trust the algorithm claim in the token header.

Pitfall 3: Storing JWTs in localStorage. localStorage is accessible to any JavaScript running on the same origin, which means that a cross-site scripting (XSS) vulnerability in your application can be used to steal the JWT. Once an attacker has the token, they can impersonate the user until the token expires. Store JWTs in HTTP-only, Secure, SameSite cookies instead. HTTP-only cookies cannot be accessed by JavaScript, which neutralises XSS-based token theft. The SameSite attribute protects against cross-site request forgery (CSRF) by preventing the cookie from being sent with cross-origin requests.

Pitfall 4: Not validating all claims. A valid signature does not mean the token is acceptable for your service. You must also verify the expiration (exp claim — reject expired tokens), the issuer (iss claim — ensure the token was issued by your trusted authentication service), the audience (aud claim — ensure the token was intended for your service and not a different service in your organisation), and the not-before time (nbf claim — reject tokens that are not yet valid). Failing to validate any of these claims can allow replay attacks, token misuse across services, or use of tokens that have been explicitly revoked.

Pitfall 5: Putting too much data in the token. JWTs are sent with every API request, so a bloated token directly impacts request latency and bandwidth consumption. Keep the payload minimal — include only the user ID, essential roles or permissions, and standard claims. Avoid embedding full user profiles, permission trees, or large arrays of data. If you need detailed permission information, store it server-side and look it up by the user ID from the token.

5

Production Best Practices for JWT Implementation

Deploying JWT-based authentication in a production environment requires attention to several operational concerns beyond the core security considerations. The following best practices will help you build a robust, maintainable authentication system.

Use strong keys and rotate them regularly. For HMAC algorithms, use a secret key of at least 256 bits (32 bytes) generated by a cryptographically secure random number generator. Never hard-code secrets in source code — use environment variables or a secrets manager like HashiCorp Vault, AWS Secrets Manager, or Azure Key Vault. Implement key rotation by supporting multiple valid keys simultaneously: when you rotate, add the new key to your verification list while keeping the old key valid for tokens that were issued before the rotation. Set a transition period (for example, 24 hours) after which old keys are fully deprecated. Include a key identifier (the "kid" header claim) in your tokens so the verifier knows which key to use.

Set appropriate token lifetimes. Access tokens should be short-lived — 15 to 60 minutes is the recommended range. Shorter lifetimes limit the window of opportunity if a token is compromised. Refresh tokens can be longer-lived (days to weeks) but must be stored securely and rotated on each use — when a refresh token is exchanged for a new access token, issue a new refresh token as well and invalidate the old one. This rotation prevents replay attacks if a refresh token is intercepted.

Implement token revocation. The stateless nature of JWTs means there is no built-in mechanism to revoke a token before it expires. In practice, you need revocation for scenarios like user logout, password changes, and account suspension. The most common approach is to maintain a short token blocklist (stored in Redis or an in-memory cache) containing the JWT IDs (jti claims) of revoked tokens. Check this blocklist on every request. Since the blocklist only needs to store tokens until they expire naturally, it remains small. An alternative approach is to maintain a token version number per user — increment the version on password change or forced logout, and reject tokens with outdated versions.

Use HTTPS exclusively. JWTs sent over unencrypted HTTP can be intercepted by anyone on the network. Enforce HTTPS on all endpoints that handle tokens — both for the initial authentication request and for all subsequent API calls. Use HSTS (HTTP Strict Transport Security) headers to prevent downgrade attacks. In cookie-based token storage, always set the Secure flag to prevent the cookie from being sent over HTTP.

Log and monitor token usage. Implement logging for authentication events — successful logins, failed verifications, token refresh operations, and revocation events. Monitor for anomalies such as tokens being used from unexpected IP addresses, rapid token refresh cycles (which may indicate token theft), or verification failures that spike suddenly (which may indicate a brute-force attack on the signing key). Integrate these logs with your observability platform and set up alerts for suspicious patterns.

6

Choosing the Right Signing Algorithm

The signing algorithm is one of the most important decisions in your JWT implementation because it determines how tokens are created and verified, and it has significant security implications. JWTs support three families of algorithms: HMAC (symmetric), RSA (asymmetric), and ECDSA (asymmetric, elliptic curve).

HS256 (HMAC with SHA-256) is the simplest option. A single secret key is used for both signing and verifying tokens. This makes it easy to implement but creates a security constraint: every service that needs to verify tokens must also possess the signing key, which means every service can also create tokens. HS256 is appropriate for monolithic applications where a single server handles both authentication and API requests, or for small teams where the operational overhead of key pair management is not justified.

RS256 (RSA Signature with SHA-256) uses a public-private key pair. The authentication service signs tokens with the private key (which it keeps secret), and all other services verify tokens with the public key (which can be freely distributed). This separation of concerns is critical in microservices architectures — even if an API service is compromised, the attacker cannot forge tokens because they do not have the private key. RS256 produces larger signatures than HS256 (approximately 256 bytes versus 32 bytes), which increases token size, but the security benefits generally outweigh the size cost in distributed systems.

ES256 (ECDSA using P-256 curve with SHA-256) provides the same security guarantees as RS256 but with much smaller signatures — approximately 64 bytes versus RS256's 256 bytes. This makes ES256 the best choice for performance-sensitive applications where token size matters (mobile apps, IoT devices). The downside is that elliptic curve cryptography is less widely supported by JWT libraries and can be harder to configure correctly. If your libraries and team expertise support it, ES256 is generally the recommended choice for new projects.

Regardless of which algorithm you choose, never use the "none" algorithm in production, never use weak keys, and never allow the token's header to dictate which algorithm your verifier uses. Specify the expected algorithm explicitly in your server-side verification code. If you need to support algorithm migration (for example, moving from HS256 to RS256), use the "kid" (Key ID) header claim to look up the correct key and algorithm for each token rather than inferring it from the header alone.

More Articles

View all