Why JWT knowledge matters in .NET interviews
JWTs are the default credential format for REST APIs, microservices, and SPAs. Interviewers
test this area because the failure modes are severe — a misconfigured TokenValidationParameters
can let attackers forge identities, replay tokens across services, or exploit expired credentials.
This article covers the full lifecycle: structure, validation, generation, refresh, and the
security pitfalls that show up in real CVEs.
What a JWT actually is
A JWT is three Base64URL-encoded JSON objects joined by dots: header.payload.signature.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiI0MiIsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20iLCJyb2xlIjoiQWRtaW4iLCJleHAiOjE3MTk5MzkyMDB9
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
| Part | Contains |
|---|---|
| Header | alg (HS256, RS256, …) and typ ("JWT") |
| Payload | Claims — registered (sub, iss, aud, exp, iat, nbf, jti) + application-specific |
| Signature | Cryptographic proof that header + payload haven't been tampered with |
The payload is encoded, not encrypted. Anyone can decode it. Only the key holder can produce a valid signature. This means:
- Integrity is guaranteed (tampered payload → invalid signature).
- Confidentiality is not — never put passwords, SSNs, or payment data in claims.
Configuring JWT bearer authentication
// NuGet: Microsoft.AspNetCore.Authentication.JwtBearer
var key = Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = builder.Configuration["Jwt:Audience"],
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
RequireExpirationTime = true,
RequireSignedTokens = true, // never set to false
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
The middleware reads Authorization: Bearer <token> from every request, validates the token
against these parameters, and if valid, populates HttpContext.User with the ClaimsPrincipal
built from the payload claims.
TokenValidationParameters — what each setting prevents
Every parameter you skip is a potential attack vector:
| Parameter | What it prevents |
|---|---|
ValidateIssuer | Tokens from untrusted issuers being accepted |
ValidateAudience | Tokens issued for api1 being replayed at api2 |
ValidateLifetime | Expired tokens being accepted indefinitely |
ValidateIssuerSigningKey | Forged tokens with a different key |
RequireSignedTokens | The alg=none attack (unsigned tokens accepted) |
ClockSkew | Legitimate tokens rejected due to minor server clock drift |
Never skip ValidateAudience. It's the most commonly missed and enables cross-API token replay —
a token legitimately issued for your mobile API being used against your admin API.
Generating tokens
public class JwtTokenService
{
private readonly IConfiguration _config;
public JwtTokenService(IConfiguration config) => _config = config;
public string GenerateAccessToken(User user)
{
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!));
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), // unique ID
new Claim(ClaimTypes.Role, user.Role),
};
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],
audience: _config["Jwt:Audience"],
claims: claims,
notBefore: DateTime.UtcNow,
expires: DateTime.UtcNow.AddMinutes(15), // short-lived
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
// Login endpoint:
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginDto dto)
{
var user = await _userService.ValidateAsync(dto.Email, dto.Password);
if (user is null) return Unauthorized();
return Ok(new
{
accessToken = _tokenService.GenerateAccessToken(user),
expiresIn = 900,
});
}
Always include jti (JWT ID) for auditability and revocation support. Keep access tokens short-lived
(≤ 15 minutes) — a stolen short-lived token is far less dangerous than a stolen 24-hour token.
Symmetric vs asymmetric signing
The signing algorithm determines who can verify tokens and what key material is needed.
// Symmetric (HS256) — one shared secret, used to both sign and verify:
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
var cred = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
// Simple — one key to manage
// Every service that validates tokens needs the secret — leaked secret compromises all
// Asymmetric (RS256) — private key signs, public key verifies:
var rsa = RSA.Create();
rsa.ImportFromPem(File.ReadAllText("private.pem"));
var privateKey = new RsaSecurityKey(rsa);
var cred = new SigningCredentials(privateKey, SecurityAlgorithms.RsaSha256);
// Public key can be shared freely — only the auth server needs the private key
// Scale: add a new microservice without sharing any secret
// Larger tokens; key rotation is more complex
// Resource server validates with public key only:
var rsaPublic = RSA.Create();
rsaPublic.ImportFromPem(File.ReadAllText("public.pem"));
options.TokenValidationParameters.IssuerSigningKey = new RsaSecurityKey(rsaPublic);
// Auto-discovery via JWKS endpoint (identity providers like Azure AD, Auth0):
options.Authority = "https://auth.myapp.com";
// Middleware fetches public keys from /.well-known/openid-configuration → jwks_uri and caches them
Use symmetric (HS256) for a single service that issues and verifies its own tokens. Use asymmetric (RS256 or ES256) in microservice architectures where multiple services verify but only one issues.
Claim mapping gotchas
ASP.NET Core maps standard JWT claim names to long WS-Federation URIs by default — a frequent source of confusion in interviews.
// Default: JWT "sub" → ClaimTypes.NameIdentifier (long URI)
// Reading after default mapping:
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); // works
// Disable mapping to use short JWT names directly:
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
// OR per handler (.NET 6+):
options.MapInboundClaims = false;
// Now read by short name:
var userId = User.FindFirstValue("sub");
var email = User.FindFirstValue("email");
// Tell the framework which claim is the "name" and which is "role":
options.TokenValidationParameters.NameClaimType = JwtRegisteredClaimNames.Sub;
options.TokenValidationParameters.RoleClaimType = "role";
// Role arrays — the handler splits them automatically:
// Payload: { "role": ["Admin", "Editor"] }
// → two separate ClaimTypes.Role claims in ClaimsPrincipal
var roles = User.FindAll("role").Select(c => c.Value).ToList();
Refresh tokens and token rotation
Access tokens are short-lived. When they expire, the client uses a long-lived refresh token to get a new access token without prompting the user again.
// Login — return both tokens:
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginDto dto)
{
var user = await _userService.ValidateAsync(dto.Email, dto.Password);
if (user is null) return Unauthorized();
var accessToken = _tokenService.GenerateAccessToken(user);
var refreshToken = await _refreshStore.CreateAsync(user.Id);
// refreshToken = random opaque string stored in DB with 30-day expiry
return Ok(new { accessToken, refreshToken, expiresIn = 900 });
}
// Refresh — rotate the refresh token on every use:
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshDto dto)
{
var stored = await _refreshStore.GetAsync(dto.RefreshToken);
if (stored is null || stored.IsRevoked || stored.ExpiresAt < DateTime.UtcNow)
return Unauthorized("Invalid or expired refresh token");
// Rotate — invalidate the used token, issue a new one:
await _refreshStore.RevokeAsync(dto.RefreshToken);
var user = await _userService.GetByIdAsync(stored.UserId);
var accessToken = _tokenService.GenerateAccessToken(user!);
var newRefresh = await _refreshStore.CreateAsync(user!.Id);
return Ok(new { accessToken, refreshToken = newRefresh, expiresIn = 900 });
}
Token rotation is critical for detecting theft: if an attacker uses a stolen refresh token before the legitimate client does, the legitimate client's next refresh attempt fails — alerting you that the token was compromised.
Handling expiration
// Server-side — the middleware rejects expired tokens automatically with 401:
// WWW-Authenticate: Bearer error="invalid_token", error_description="The token is expired"
// Customize the 401 response body:
options.Events = new JwtBearerEvents
{
OnChallenge = ctx =>
{
ctx.HandleResponse();
ctx.Response.StatusCode = 401;
ctx.Response.ContentType = "application/json";
return ctx.Response.WriteAsJsonAsync(new
{
error = "token_expired",
message = "Access token expired. Use the refresh endpoint.",
});
},
};
// Client-side — proactively refresh before expiry to avoid a failed request:
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(accessToken);
if (jwtToken.ValidTo < DateTime.UtcNow.AddSeconds(30))
{
accessToken = await _authClient.RefreshAsync(refreshToken);
}
Revoking JWTs
Because JWTs are stateless, revoking one before expiry requires infrastructure. Three patterns:
1. Server-side blocklist (Redis/cache) — add the jti to a blocklist on logout; check it on
every request. Accurate but adds latency per request.
options.Events = new JwtBearerEvents
{
OnTokenValidated = async ctx =>
{
var jti = ctx.Principal!.FindFirstValue(JwtRegisteredClaimNames.Jti);
var blocklist = ctx.HttpContext.RequestServices.GetRequiredService<TokenBlocklist>();
if (jti is not null && await blocklist.IsRevokedAsync(jti))
ctx.Fail("Token revoked");
},
};
2. Short access tokens + refresh token revocation — access tokens expire in ≤ 15 minutes; on logout, revoke only the refresh token in the database. Stolen access tokens are usable for at most 15 minutes — acceptable risk for most apps.
3. Reference tokens — store an opaque ID in the token; validate against a database on every request. Full immediate revocation but eliminates the stateless benefit entirely.
For most applications, pattern 2 is the right trade-off.
Security vulnerabilities and how to prevent them
1. alg=none attack
An attacker creates a token with "alg": "none" and no signature. If the server accepts it, any
payload is trusted.
Prevention: RequireSignedTokens = true (the default — never override it).
2. Algorithm confusion (RS256 → HS256)
The server's RSA public key is used as the HMAC secret. If the server accepts HS256 when it expects RS256, the attacker can forge tokens using the public key (which is, by definition, public).
Prevention: Restrict accepted algorithms explicitly.
options.TokenValidationParameters.ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 };
3. Cross-API token replay
A token legitimately issued for api1 is used at api2.
Prevention: ValidateAudience = true with ValidAudience set to the current service's
audience identifier.
4. Weak HMAC secrets
Short or guessable secrets can be brute-forced offline. An attacker who captures any valid token can run dictionary attacks against the signature.
Prevention: Use a cryptographically random secret of at least 256 bits (32 bytes).
var secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
// Store in Azure Key Vault / AWS Secrets Manager — not in appsettings.json.
5. Insecure client storage
Storing JWTs in localStorage makes them readable by any JavaScript on the page — one XSS
vulnerability anywhere on the site steals every user's token.
Prevention: Store tokens in HttpOnly, Secure, SameSite=Strict cookies. JavaScript can't
read HttpOnly cookies. Use CSRF tokens or SameSite=Strict to defend against CSRF.
Response.Cookies.Append("access_token", token, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
Expires = DateTimeOffset.UtcNow.AddMinutes(15),
});
// Read from cookie in the middleware:
options.Events = new JwtBearerEvents
{
OnMessageReceived = ctx =>
{
ctx.Token = ctx.Request.Cookies["access_token"];
return Task.CompletedTask;
},
};
Recap
JWTs are signed bearer credentials — integrity-guaranteed but not secret. Configure
TokenValidationParameters with all five validations: issuer, audience, lifetime, signing key, and
RequireSignedTokens. Generate tokens with JwtSecurityTokenHandler, always including jti and a
short expiry. Use symmetric signing for single-service scenarios, asymmetric for multi-service. Pair
access tokens (≤ 15 min) with refresh tokens and rotate the refresh token on every use to detect
theft. Store tokens in HttpOnly cookies on the client to defeat XSS. Never disable
RequireSignedTokens, never skip audience validation, and never store secrets in appsettings.json.