JWT Fundamentals — Structure, Claims and Validation

A JSON Web Token (JWT) is a compact, self-contained token format for securely transmitting claims between parties. In the Angular-to-ASP.NET Core API architecture, the Angular client authenticates once (sends username/password), receives a JWT, and includes that JWT in every subsequent API request via the Authorization: Bearer {token} header. The API validates the token’s signature and expiry on every request — no session database, no cookie, stateless authentication. Understanding the JWT structure and how validation works is the foundation for the entire auth system.

JWT Structure and Claims

// ── JWT: three Base64URL-encoded parts separated by dots ──────────────────
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9     ← Header
// .eyJzdWIiOiJ1c2VyLTEiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJyb2xlcyI6WyJBZG1pbiJdLCJpYXQiOjE3MDY3NDU2MDAsImV4cCI6MTcwNjc0OTIwMCwiaXNzIjoiaHR0cHM6Ly9ibG9nYXBwLmNvbSIsImF1ZCI6Imh0dHBzOi8vYmxvZ2FwcC5jb20ifQ
// .SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c   ← Signature

// ── Decoded header ────────────────────────────────────────────────────────
// { "alg": "HS256", "typ": "JWT" }

// ── Decoded payload (claims) ──────────────────────────────────────────────
// {
//   "sub":   "user-1",              ← Subject (user ID)
//   "email": "user@example.com",
//   "roles": ["Admin", "Editor"],   ← Custom role claims
//   "iat":   1706745600,            ← Issued at (Unix timestamp)
//   "exp":   1706749200,            ← Expires at (15 minutes later)
//   "iss":   "https://blogapp.com", ← Issuer
//   "aud":   "https://blogapp.com"  ← Audience
// }

// ── Configure JWT Bearer authentication ───────────────────────────────────
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        var jwtSettings = builder.Configuration.GetSection("JwtSettings");

        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey         = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(jwtSettings["SecretKey"]!)),

            ValidateIssuer   = true,
            ValidIssuer      = jwtSettings["Issuer"],

            ValidateAudience = true,
            ValidAudience    = jwtSettings["Audience"],

            ValidateLifetime    = true,      // reject expired tokens
            ClockSkew           = TimeSpan.Zero, // no grace period past exp
        };

        // Allow JWT from query string for SignalR WebSocket connections
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = ctx =>
            {
                var accessToken = ctx.Request.Query["access_token"];
                var path        = ctx.HttpContext.Request.Path;
                if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
                    ctx.Token = accessToken;
                return Task.CompletedTask;
            }
        };
    });
Note: The JWT signature is the security guarantee. It is computed as HMAC-SHA256(Base64Url(header) + "." + Base64Url(payload), secretKey). Any modification to the header or payload invalidates the signature — ASP.NET Core’s JWT validation rejects the token. However, the payload is only Base64URL-encoded, not encrypted — anyone who possesses the token can read the claims. Never store sensitive data (passwords, SSNs) in a JWT payload; store only the identifiers and roles needed for authorisation. The claims are readable but unforgeable.
Tip: Set ClockSkew = TimeSpan.Zero in TokenValidationParameters to enforce exact token expiry. The default 5-minute clock skew allows tokens to be used up to 5 minutes after their stated expiry time — designed to tolerate server clock drift. For tightly controlled environments, zero skew is safer. If you use short-lived tokens (15-minute access tokens), zero skew means they expire precisely when they should, prompting the Angular client to refresh them on schedule.
Warning: Never store the JWT signing secret in appsettings.json committed to source control. Anyone with read access to the repository can forge tokens with any claims they choose. Store the signing secret in environment variables (JwtSettings__SecretKey=...), User Secrets during development (dotnet user-secrets set "JwtSettings:SecretKey" "..."), or Azure Key Vault in production. The secret must be at least 256 bits (32 bytes) for HMAC-SHA256. A weak or predictable secret is cryptographically equivalent to no authentication.
Concern JWT Bearer Cookies
Angular SPA usage ✅ Natural fit (HttpClient header) Works but requires cookie config
Mobile app usage ✅ Easy — set Authorization header Difficult — cookie handling varies
CSRF protection ✅ Not needed (headers not auto-sent) Requires CSRF tokens
XSS risk for token storage High if localStorage ✅ HttpOnly cookie (JS inaccessible)
Revocation (logout) Requires denylist or short TTL ✅ Delete session server-side
Stateless API (multiple servers) ✅ No shared session state needed Requires shared session store

Common Mistakes

Mistake 1 — Setting ClockSkew to default (tokens usable 5 minutes after expiry)

❌ Wrong — default 5-minute skew; attacker can use a “expired” token for 5 extra minutes.

✅ Correct — set ClockSkew = TimeSpan.Zero for precise expiry enforcement.

Mistake 2 — Putting sensitive data in JWT payload (visible to anyone)

❌ Wrong — storing password hash or SSN in claims; anyone with the token can decode and read it.

✅ Correct — store only identifiers and permissions (userId, roles); never sensitive personal data.

🧠 Test Yourself

An attacker intercepts a JWT and modifies the roles claim to include “Admin”. Does ASP.NET Core accept this modified token?