Back to blog
Security & Complianceintermediate

JWT Deep Dive — Structure, Attacks, and What Senior Devs Do Differently

Understand JWT structure, base64url encoding, signing algorithms, critical attacks like alg:none and algorithm confusion, token storage trade-offs, refresh token rotation, and revocation strategies.

LearnixoApril 15, 20267 min read
SecurityJWTAuthenticationOAuthC#.NET
Share:𝕏

What Is a JWT?

A JSON Web Token (JWT) is a compact, URL-safe way to represent claims between two parties. You see them everywhere — as access tokens, ID tokens, and sometimes session tokens. Understanding their internals is non-negotiable for any developer touching auth.

A JWT looks like this:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcxMDAwMDAwMH0.SIGNATURE

Three base64url-encoded parts separated by dots: header.payload.signature.

The Three Parts

Header — algorithm and token type:

JSON
{
  "alg": "RS256",
  "typ": "JWT"
}

Payload — the claims:

JSON
{
  "sub": "user_123",
  "role": "admin",
  "exp": 1710000000,
  "iat": 1709996400
}

Signature — cryptographic proof the token hasn't been tampered with.

Base64url encoding is NOT encryption. Anyone can decode the header and payload. Never put secrets in a JWT payload.

Signing Algorithms — HS256 vs RS256 vs ES256

This is where most developers make their first mistake.

| Algorithm | Type | Key | Use When | |-----------|------|-----|----------| | HS256 | Symmetric HMAC | Shared secret | Single service, secret never leaves server | | RS256 | Asymmetric RSA | Private/public key pair | Multiple services, public key distributed | | ES256 | Asymmetric ECDSA | Private/public key pair | Same as RS256, smaller tokens, preferred |

HS256 is fine when one service both issues and validates tokens. The moment you have multiple services validating tokens, you have to share the secret — which defeats the purpose.

RS256/ES256: the issuer signs with the private key. Any service can validate using the public key fetched from the issuer's JWKS endpoint. The private key never leaves the auth server.

Use RS256 or ES256 in almost every production system.

The alg: none Attack

In 2015, several JWT libraries accepted a token with "alg": "none" in the header — meaning no signature required. An attacker could:

  1. Decode any JWT (it's just base64url)
  2. Modify the payload ("role": "admin")
  3. Set "alg": "none" in the header
  4. Remove the signature
  5. Send the crafted token

Fix: always explicitly specify which algorithms you accept. Never trust the alg field from the token to decide how to validate it.

C#
// WRONG — library uses alg from token header
var handler = new JwtSecurityTokenHandler();
handler.ValidateToken(token, new TokenValidationParameters
{
    // No ValidateAlgorithm constraint — dangerous
}, out _);

// RIGHT — pin the algorithm
var validationParams = new TokenValidationParameters
{
    ValidAlgorithms = new[] { "RS256" },
    ValidateIssuerSigningKey = true,
    IssuerSigningKey = GetPublicKey(),
    ValidateIssuer = true,
    ValidIssuer = "https://auth.example.com",
    ValidateAudience = true,
    ValidAudience = "api.example.com",
    ValidateLifetime = true,
    ClockSkew = TimeSpan.FromSeconds(30)
};

The Algorithm Confusion Attack (RS256 → HS256)

This attack is subtle. Some libraries, when configured for RS256, will also accept HS256 if presented. An attacker:

  1. Fetches your public key from the JWKS endpoint (it's public — that's the point)
  2. Uses that public key as the HMAC secret to sign a forged token with "alg": "HS256"
  3. Some libraries validate it successfully because they switch algorithms based on the token header

Fix: pin algorithms explicitly as shown above. Set ValidAlgorithms and never allow the token to dictate the algorithm.

Token Storage — localStorage vs HttpOnly Cookie

This is a genuine trade-off, not a clear winner.

localStorage

  • Accessible from JavaScript
  • Vulnerable to XSS — if an attacker runs JS on your page, they steal the token
  • Not automatically sent by the browser (no CSRF risk)
  • Easy to implement in SPAs

HttpOnly cookie

  • Not accessible from JavaScript — XSS cannot steal it
  • Automatically sent by the browser (requires CSRF protection)
  • Works well with SameSite=Strict or SameSite=Lax
  • Slightly more complex setup

Senior recommendation: HttpOnly, Secure, SameSite=Strict cookie with a short-lived access token. This eliminates XSS token theft and, with SameSite, eliminates CSRF without needing additional tokens.

Short Expiry + Refresh Token Rotation

Access tokens should be short-lived — 5 to 15 minutes. When they expire, the client uses a refresh token to get a new access token.

Refresh token rotation: every time a refresh token is used, it's invalidated and a new one is issued. If the old refresh token is used again (replay attack), invalidate the entire token family and force re-login.

C#
public async Task<TokenResponse> RefreshAsync(string refreshToken)
{
    var stored = await _db.RefreshTokens
        .FirstOrDefaultAsync(t => t.Token == refreshToken);

    if (stored == null)
        throw new SecurityException("Invalid refresh token");

    if (stored.IsRevoked)
    {
        // Possible token theft — revoke entire family
        await RevokeTokenFamilyAsync(stored.FamilyId);
        throw new SecurityException("Refresh token reuse detected");
    }

    // Rotate
    stored.IsRevoked = true;
    var newRefreshToken = GenerateRefreshToken(stored.FamilyId);
    await _db.RefreshTokens.AddAsync(newRefreshToken);
    await _db.SaveChangesAsync();

    var accessToken = GenerateAccessToken(stored.UserId);
    return new TokenResponse(accessToken, newRefreshToken.Token);
}

What to Put in Claims (and What NOT To)

Safe to include:

  • sub — user ID (opaque identifier)
  • roles or permissions
  • tenant_id for multi-tenant apps
  • Standard claims: exp, iat, iss, aud, jti

Never include:

  • Passwords or password hashes
  • PII (email, name, address) — the payload is readable by anyone
  • Health data, financial data
  • Anything you wouldn't want logged

If your gateway logs Authorization headers (many do), every claim in every token ends up in your logs. Keep payloads small and opaque.

JWK Sets for Public Key Distribution

C#
// Fetch JWKS from auth server at startup, cache with refresh
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://auth.example.com";
        // This automatically fetches /.well-known/openid-configuration
        // and the JWKS endpoint for public key rotation
        options.MetadataAddress = "https://auth.example.com/.well-known/openid-configuration";
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidAlgorithms = new[] { "RS256" },
            ValidAudience = "api.example.com"
        };
    });

The library handles key rotation automatically — when a token presents a kid (key ID) not in the cached JWKS, it re-fetches.

Token Revocation

JWTs are stateless — the server doesn't store them. This makes revocation hard. Your options:

Short expiry — the simplest "revocation". If tokens expire in 5 minutes, stolen tokens have limited value.

Token blocklist — store revoked jti values in Redis until they expire. Check on every request. Fast for small sets, scales poorly.

Token versioning — store a token_version on the user record. Include it in the JWT. On validation, check the user's current version. Logout increments the version, invalidating all tokens. One DB lookup per request.

C#
// On token generation
var claims = new[]
{
    new Claim("sub", userId),
    new Claim("token_version", user.TokenVersion.ToString())
};

// On validation (custom middleware or event)
options.Events = new JwtBearerEvents
{
    OnTokenValidated = async ctx =>
    {
        var userId = ctx.Principal.FindFirst("sub")?.Value;
        var tokenVersion = ctx.Principal.FindFirst("token_version")?.Value;
        var user = await userService.GetByIdAsync(userId);

        if (user.TokenVersion.ToString() != tokenVersion)
        {
            ctx.Fail("Token has been revoked");
        }
    }
};

Generating JWTs in C#

C#
public string GenerateAccessToken(string userId, string[] roles)
{
    var privateKey = LoadRsaPrivateKey(); // from Key Vault, not appsettings
    var signingCredentials = new SigningCredentials(
        new RsaSecurityKey(privateKey),
        SecurityAlgorithms.RsaSha256
    );

    var claims = new List<Claim>
    {
        new Claim(JwtRegisteredClaimNames.Sub, userId),
        new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
        new Claim(JwtRegisteredClaimNames.Iat,
            DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
            ClaimValueTypes.Integer64)
    };
    claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));

    var token = new JwtSecurityToken(
        issuer: "https://auth.example.com",
        audience: "api.example.com",
        claims: claims,
        notBefore: DateTime.UtcNow,
        expires: DateTime.UtcNow.AddMinutes(15),
        signingCredentials: signingCredentials
    );

    return new JwtSecurityTokenHandler().WriteToken(token);
}

Key Takeaways

  • Pin your algorithm — never trust the alg field from an untrusted token
  • Use RS256 or ES256 for any multi-service architecture
  • Keep access tokens short-lived (15 min), rotate refresh tokens
  • HttpOnly + SameSite cookies beat localStorage for XSS resistance
  • Never put PII or sensitive data in the payload — it's readable by anyone
  • Token versioning is the most practical revocation strategy

Enjoyed this article?

Explore the Security & Compliance learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.