Skip to content
Back to blog
JWTSecurityAuthenticationAPI.NET

JWT Deep Dive: What's Inside a Token and How to Validate It Safely

July 4, 202613 min read

JSON Web Tokens are everywhere — issued by every OAuth 2.0 provider, sent in every Authorization: Bearer header, decoded by every modern API. But most developers treat them as black boxes: 'it's three Base64 strings separated by dots, it works, move on.' That vagueness is how security vulnerabilities sneak in.

This article opens the black box. You'll see exactly what's inside a JWT, how signing works and why RS256 beats HS256 for most systems, what validation steps you must perform (skipping any one of them is a real vulnerability), how refresh tokens work, and the most common JWT mistakes teams make in production — including one that lets an attacker forge any token.

Anatomy of a JWT

A JWT is three Base64URL-encoded strings joined by dots: header.payload.signature. They're not encrypted (unless you use JWE, a separate spec) — they're signed. Anyone can decode the header and payload. The signature is what you trust.

The header declares the token type and signing algorithm. The payload holds claims — standardized fields like iss (issuer), sub (subject/user ID), exp (expiration), aud (intended audience), and iat (issued-at), plus any custom claims your application adds. The signature is computed over the header and payload using the algorithm declared in the header and a secret or private key held by the authorization server.

Quick reference

  • Base64URL encoding ≠ encryption. Anyone with the token can decode the header and payload.
  • Never put secrets (passwords, credit cards, SSNs) in JWT claims — treat them as public data.
  • exp is a Unix timestamp in seconds (not milliseconds). Off-by-1000x is a common bug.
  • iat (issued-at) lets you invalidate tokens issued before a certain time — useful after a password reset.
  • Custom claims should use namespaced keys (https://myapp.com/role) to avoid collision with registered claims.
Before
Encoded JWT (what travels over the wire)
1eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ92.eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJBbGljZSBTbWl0aCIsInJvbGUiOiJhZG1pbiIsImlzcyI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLmNvbSIsImF1ZCI6Im15LWFwaSIsImlhdCI6MTc1MTYwNjQwMCwiZXhwIjoxNzUxNjEwMDAwfQ3.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
After
Decoded header and payload (Base64URL decoded)
1// Header2{3  "alg": "RS256",   // signing algorithm4  "typ": "JWT"5}6 7// Payload (claims)8{9  "sub": "user_123",              // subject — who this token is for10  "name": "Alice Smith",11  "role": "admin",12  "iss": "https://auth.example.com",  // issuer13  "aud": "my-api",               // intended audience14  "iat": 1751606400,             // issued at (Unix timestamp)15  "exp": 1751610000              // expires at (1 hour later)16}17 18// Signature (opaque — verify, don't decode)19// = RS256(base64url(header) + "." + base64url(payload), privateKey)

Remember this

A JWT is a signed assertion, not an encrypted secret. Its claims are readable by anyone — only trust the signature.

HS256 vs RS256: Which Algorithm to Use

HS256 (HMAC-SHA256) uses a single shared secret to both sign and verify tokens. Every system that validates the token must hold the same secret. This creates a key distribution problem: your auth server and every API that validates tokens all share one secret — a compromise anywhere exposes all systems.

RS256 (RSA-SHA256) uses a private/public key pair. The auth server signs with the private key. APIs verify with the public key. The public key can be distributed freely (usually at /.well-known/jwks.json). Compromising an API never exposes the signing key because APIs only hold the public key. For systems with multiple microservices or third-party token consumers, RS256 is the right default.

Quick reference

  • HS256: one secret, shared everywhere. Simple but risky in distributed systems.
  • RS256: private key signs (auth server only), public key verifies (all APIs). Key rotation is safe.
  • ES256 (ECDSA): similar to RS256 but smaller signatures and faster verification. Good choice for high-throughput APIs.
  • PS256 (RSA-PSS): more secure than RS256 mathematically. Preferred in FAPI (Financial-grade API) specs.
  • Fetch public keys from the JWKS endpoint at startup and cache them — don't fetch per-request.
  • Rotate signing keys with a key ID (kid) header — publish new key, let old tokens expire, then remove old key.

Remember this

Use RS256 (or ES256) for any system with multiple token consumers. HS256 is only appropriate when you fully control every verifier.

Validating a JWT: Every Step Matters

JWT validation is not just signature verification. There are seven checks, and skipping any one of them leaves a real vulnerability. A token that is correctly signed but expired should still be rejected. A token with the wrong audience is not intended for your API — accept it and you're a confused deputy.

Most JWT libraries validate all of these automatically when configured correctly. The risk is misconfiguration — setting validateLifetime: false 'temporarily for testing' and shipping it to production. Know what your library checks by default and what you must enable explicitly.

Quick reference

  • 1. Verify signature — reject tokens where header + payload + key doesn't match the signature.
  • 2. Check algorithm (alg) — reject 'none'. Only accept your expected algorithm (RS256, ES256).
  • 3. Validate issuer (iss) — only accept tokens from your auth server.
  • 4. Validate audience (aud) — only accept tokens issued for your API.
  • 5. Check expiry (exp) — reject tokens past their expiration, with a small clock-skew tolerance.
  • 6. Check not-before (nbf) — reject tokens not yet valid.
  • 7. Check token revocation — if needed, check a blocklist (Redis set of revoked jti values).
Before
Dangerous — only checks signature
1// DO NOT DO THIS2const decoded = jwt.decode(token); // no verification at all!3const payload = JSON.parse(4  Buffer.from(token.split('.')[1], 'base64').toString()5);6// Anyone can craft claims and forge this
After
.NET — full validation configured explicitly
1builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)2    .AddJwtBearer(options =>3    {4        options.Authority = "https://auth.example.com"; // fetches JWKS automatically5        options.Audience = "my-api";6        options.TokenValidationParameters = new TokenValidationParameters7        {8            ValidateIssuer = true,9            ValidIssuer = "https://auth.example.com",10 11            ValidateAudience = true,12            ValidAudience = "my-api",13 14            ValidateLifetime = true,           // checks exp and nbf15            ClockSkew = TimeSpan.FromSeconds(30), // allow 30s drift16 17            ValidateIssuerSigningKey = true,   // verifies signature18            // Key fetched from Authority's JWKS endpoint automatically19        };20    });

Remember this

Validate all seven fields, not just the signature. One skipped check is one attack vector.

Access Tokens and Refresh Tokens

Short-lived access tokens (5–15 minutes) limit the damage of a stolen token — it expires before an attacker can do much. But forcing users to log in every 15 minutes is terrible UX. Refresh tokens solve this: a long-lived credential (days or weeks) used only to get new access tokens.

The refresh token lives in an httpOnly cookie or secure storage. When the access token expires, the client sends the refresh token to the auth server's /token endpoint. The server validates the refresh token, issues a new access token, and optionally rotates the refresh token (refresh token rotation — the old one is immediately invalidated). If a stolen refresh token is used, the rotation detects token reuse and can revoke the family.

Quick reference

  • Access token lifetime: 5–15 minutes. Never long-lived — you can't revoke a JWT without a blocklist.
  • Refresh token lifetime: 1 day to 30 days depending on risk tolerance and user behavior.
  • Refresh token rotation: issue a new refresh token on every refresh, invalidate the old one. Detects theft.
  • Store refresh tokens in httpOnly, Secure, SameSite=Strict cookies — not localStorage or sessionStorage.
  • Sliding expiry: extend refresh token lifetime on activity. Absolute expiry: force re-login after max time.
  • Token revocation: maintain a blocklist (Redis) of revoked jti (JWT ID) values for high-risk operations like logout.

Remember this

Pair short-lived access tokens with refresh tokens and rotation. Never make access tokens long-lived to avoid implementing refresh — that's a shortcut with a large blast radius.

JWT Mistakes That Cause Real Vulnerabilities

The most famous JWT vulnerability is the algorithm confusion attack: an attacker changes the header from RS256 to HS256, then signs the token with the server's public key as the HMAC secret. Libraries that blindly trusted the alg claim would then verify the forged token using the public key — and succeed. Modern libraries have fixed this, but it illustrates why you must specify the expected algorithm in your validator, never accept the algorithm from the token itself.

Other common mistakes include no expiry (tokens valid forever), storing tokens in localStorage (vulnerable to XSS), skipping audience validation (any token from your auth server is accepted by any service), and hardcoding weak secrets (HS256 with 'secret' or 'changeme' — brute-forceable in seconds).

Quick reference

  • Algorithm confusion (alg:none or RS256→HS256): always specify accepted algorithms in your validator.
  • No expiry (exp): every token should expire. For long-running machine processes, rotate tokens on schedule.
  • localStorage storage: XSS can steal tokens. Use httpOnly cookies or memory storage for SPAs.
  • Weak HS256 secrets: use at least 256 bits of cryptographically random bytes. Not a password.
  • Missing audience validation: your auth server issues tokens for multiple services — validate aud.
  • Logging tokens: never log Authorization headers. Treat tokens like passwords in logs.

Remember this

Whitelist the algorithm in your validator (never trust the token's alg claim), set expiry on every token, and store tokens in httpOnly cookies when possible.

Key takeaway

Share:

JWTs are powerful and widely misused. The good news is that with a well-configured library, most validation is automatic. The risk is in the configuration: an algorithm not whitelisted, an audience not checked, a lifetime set to 'none for now.' Treat JWT configuration like any other security-sensitive code — review it explicitly, test it with invalid inputs, and document what each parameter does and why.

For most backend APIs: use RS256, validate all seven fields, issue access tokens for 15 minutes, pair them with rotating refresh tokens in httpOnly cookies. That setup handles the overwhelming majority of real-world threats without requiring a token revocation database.

Related Articles

Authentication in modern apps spans three distinct models. Sessions store user state on the server and send a session ID

Read

Every REST API needs a way to verify who is calling it and what they are allowed to do. The method you choose shapes you

Read

SOLID is five principles for writing object-oriented code that's easy to extend without breaking existing behavior. They

Read

Explore this topic

Keep learning

Follow a structured path or browse all courses to go deeper.