OAuth 2.0 and OpenID Connect are the two specifications almost every .NET system built in the last decade depends on for authentication and delegated authorization, yet most engineers only ever configure them through a library's default settings and never reason about the protocol underneath. That gap shows up immediately in senior and architect interviews: candidates can usually recite what an access token is, but stumble the moment they're asked why PKCE is mandatory even for a confidential client, how a stolen refresh token gets detected, or why an ID token should never reach a resource API. For engineers with ten to twenty years of experience, these questions are less about protocol trivia and more about judgment — knowing which flow fits which client type, where JSON Web Token validation quietly goes wrong, and how to keep tokens away from an attacker who has already achieved cross-site scripting in the browser. The ten questions below cover the authorization code flow with PKCE, the client credentials grant, the roles of access, ID and refresh tokens, JWT validation pitfalls, refresh token rotation, SPA token storage through the Backend-for-Frontend pattern, scopes versus roles, and OAuth 2.1's status.
Q1 Walk through the authorization code flow with PKCE, and explain why it's now required for every client, not just public ones.#
Short answer: The client sends the user to the authorization server with a code challenge, the user authenticates and consents, the authorization server redirects back with a short-lived authorization code, and the client exchanges that code plus the original code verifier for tokens at a back-channel token endpoint; PKCE proves that whoever redeems the code is the same party who started the request, so an intercepted code is useless without the verifier that generated its challenge.
using System.Security.Cryptography;
using System.Text;
var verifierBytes = RandomNumberGenerator.GetBytes(32);
var codeVerifier = Convert.ToBase64String(verifierBytes)
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
var challengeBytes = SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier));
var codeChallenge = Convert.ToBase64String(challengeBytes)
.TrimEnd('=').Replace('+', '-').Replace('/', '_');Historically, PKCE (Proof Key for Code Exchange) was sold as a mitigation for public clients — mobile and single-page apps that can't hold a client secret, where a malicious app registering the same custom URI scheme could intercept the redirect and steal the code. The OAuth 2.1 consolidation makes PKCE mandatory for every client, confidential or not, because the interception threat isn't limited to custom URI schemes: a misconfigured redirect URI, a logging proxy, a browser extension or a referrer header can all leak an authorization code in ways that have nothing to do with the client's ability to keep a secret. The S256 challenge method (SHA-256 of the verifier) is the only method OAuth 2.1 permits; the older plain method, where the challenge equals the verifier in cleartext, is explicitly removed because it provides no protection against an attacker who can already see the authorization request.
What interviewers look for: understanding that PKCE binds the token exchange to the party that initiated the flow, not just "public clients need it," and awareness that OAuth 2.1 extends the requirement to confidential clients too.
Common mistakes: generating the code verifier on a server and reusing it across multiple login attempts, implementing plain instead of S256, and treating PKCE as a replacement for validating the redirect URI with exact string matching rather than as a complement to it.
Q2 When would you use the client credentials grant, and what should its client authentication look like in production?#
Short answer: Use the client credentials grant when a service needs to call an API on its own behalf, with no user in the loop — a batch job, a backend integration or a daemon — and in production, authenticate that client with a certificate (private_key_jwt) or mutual TLS rather than a static client secret, since a shared secret is just a long-lived password sitting in configuration.
The grant itself is simple: the client posts its credentials directly to the token endpoint and receives an access token scoped to whatever the authorization server allows that client to have, with no authorization code, no redirect and no refresh token, because there's no user session to keep alive — if the access token expires, the client just requests a new one using the same credentials. The part senior candidates often gloss over is what "client credentials" should actually be. A client secret is operationally convenient but has all the problems of a password: it's usually valid for a long time, it ends up in environment variables and CI logs, and rotating it means coordinating a deployment. Asymmetric client authentication — the client signs a short-lived JWT assertion with a private key it never transmits, or the client and server negotiate mutual TLS — means there's no shared secret to leak in the first place, only a public key or certificate the authorization server already trusts. In Azure and similar platforms, workload identity federation goes a step further and removes the stored credential entirely, letting the client authenticate with a short-lived token issued by its own runtime (a Kubernetes service account token, a GitHub Actions OIDC token) that the authorization server trusts without any secret changing hands.
What interviewers look for: recognizing that "client credentials grant" and "client secret" aren't the same decision, and that production-grade service-to-service auth should default to certificate- or federation-based client authentication over shared secrets.
Q3 What's the practical difference between an access token, an ID token and a refresh token, and why shouldn't a resource API accept an ID token?#
Short answer: An access token authorizes a call to a specific resource API and is the only one of the three a resource server should ever validate; an ID token is a JWT that proves the user authenticated to the client application itself and is consumed by that client, not by downstream APIs; a refresh token is a long-lived credential used only at the token endpoint to obtain new access tokens without re-prompting the user.
| Token | Audience | Consumed by | Typical lifetime |
|---|---|---|---|
| Access token | A specific resource/API | The resource server | Minutes |
| ID token | The client application | The client, once, at login | Minutes |
| Refresh token | The authorization server | The token endpoint only | Hours to days, or until revoked |
The confusion between access tokens and ID tokens is one of the most common OpenID Connect mistakes in production systems. An ID token's aud claim is the client's own client ID, not any API's identifier, so a resource server that accepts it is trusting a token that was never meant to authorize access to it — if that resource server is loose about audience validation, any client that can obtain any ID token from the same identity provider could potentially reuse it. Access tokens, by contrast, are either opaque reference tokens the resource server must introspect, or structured JWTs following the JWT Profile for OAuth 2.0 Access Tokens (RFC 9068), which standardizes claims like aud, scope and client_id so a resource server can validate them locally without calling back to the authorization server on every request.
What interviewers look for: a clean, audience-based explanation (not just "ID token is for login, access token is for APIs") and awareness that mixing them up is a real, exploitable authorization bug, not a style preference.
Q4 What are the most common mistakes engineers make validating JWTs, and how do you avoid them in ASP.NET Core?#
Short answer: The recurring mistakes are trusting the algorithm named in the token header instead of pinning an expected algorithm, skipping issuer or audience validation, not handling signing-key rotation, and treating "the signature verified" as equivalent to "this token is valid for this request" without also checking expiry, not-before and the claims the endpoint actually needs.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://login.example-tenant.com/";
options.Audience = "api://orders-api";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
ClockSkew = TimeSpan.FromMinutes(2),
};
});The classic "algorithm confusion" attack relies on a validator that reads the alg header from the untrusted token itself and uses it to decide how to verify the signature — if the library allows none or lets an RSA-signed token be re-verified as HMAC using the public key as the HMAC secret, an attacker can forge tokens the server will accept. Pinning ValidAlgorithms closes that door by refusing anything outside an explicit allowlist regardless of what the token claims. Skipping ValidateIssuer or ValidateAudience is the second most common gap: a token correctly signed by a trusted issuer for a different audience or application will still pass signature verification, so those checks are what actually enforces "this token was meant for me." Setting options.Authority lets the JWT Bearer handler fetch signing keys from the identity provider's discovery document and JWKS endpoint automatically, including transparent handling of key rotation via the kid header — hand-rolling key resolution without that support is what causes outages the moment the identity provider rotates its signing key.
What interviewers look for: naming algorithm confusion specifically, distinguishing signature validity from claim validity, and knowing that Authority plus discovery handles key rotation instead of a hardcoded key.
Common mistakes: disabling ValidateIssuer or ValidateAudience "temporarily" during debugging and shipping it that way, trusting unverified claims (like a role claim) before the signature check completes, and setting ClockSkew to zero, which causes intermittent failures against real-world clock drift.
Q5 How does refresh token rotation work, and how do you detect that a refresh token has been stolen?#
Short answer: On rotation, every use of a refresh token issues a new refresh token and invalidates the one just used, so a legitimate client and an attacker who copied the same token can't both keep using it silently; if the old, already-invalidated token is ever presented again, the authorization server treats that as reuse, which is a strong signal of theft, and revokes the entire token family rather than just the one token.
if (storedToken.IsRevoked)
{
await tokenStore.RevokeFamilyAsync(storedToken.FamilyId, ct);
throw new SecurityTokenException("Refresh token reuse detected.");
}
await tokenStore.RevokeAsync(storedToken.Id, ct);
var (accessToken, nextRefreshToken) =
await tokenService.IssueAsync(storedToken.FamilyId, storedToken.UserId, ct);Rotation only works as a detection mechanism if the authorization server tracks token families: each refresh token remembers which token it was issued in exchange for, so if token N is redeemed, is marked used, and token N is presented again later, the server knows either a client retried a request against a stale token (recoverable, usually handled with a short grace window) or an attacker who captured token N is now racing the legitimate client. OAuth 2.1 requires that refresh tokens issued to public clients be either sender-constrained — bound to the client via a mechanism like DPoP so a stolen token can't be replayed from a different device — or one-time use with rotation, precisely because public clients can't hold a secret that would otherwise prove possession. Confidential clients get more latitude because their own client authentication already provides part of that proof.
What interviewers look for: the family-based reuse-detection model specifically, not just "rotate the token," and awareness of the OAuth 2.1 requirement that public-client refresh tokens be rotated or sender-constrained.
Follow-up questions:
- What should happen to a user's other active sessions when reuse is detected on one of them?
- How would sender-constraining with DPoP change this design?
Q6 Where should a single-page application store its access and refresh tokens, and why has the Backend-for-Frontend pattern become the recommended approach?#
Short answer: Neither token should ever reach the browser's JavaScript context — localStorage, sessionStorage and even in-memory JavaScript variables are all readable by any script that runs on the page, so a single cross-site scripting bug anywhere in the app or its dependencies is enough to exfiltrate them; the Backend-for-Frontend (BFF) pattern avoids the problem entirely by keeping the SPA a public client with no tokens at all and moving the token exchange to a confidential server-side component the browser talks to only through an HttpOnly session cookie.
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict;In the BFF pattern, the SPA never runs the OAuth flow itself; a small server-side application (which can be the same ASP.NET Core app serving the SPA's static files, or a dedicated gateway) performs the authorization code exchange, holds the access and refresh tokens server-side, and issues the browser an encrypted, HttpOnly session cookie that JavaScript cannot read even if an XSS payload executes on the page. Every API call the SPA makes goes to the BFF, which attaches the real access token to the outbound request before forwarding it — the browser only ever sees the session cookie, never the token. This also sidesteps refresh-token exposure: a BFF holds refresh tokens server-side, no more exposed than any other backend secret, and SameSite=Strict plus standard CSRF defenses handle the cookie-specific risk that replaces token theft as the main threat.
What interviewers look for: a direct explanation of why browser storage is unsafe (XSS, not just "it's insecure"), and a working understanding of the BFF pattern's mechanics, not just its name.
Q7 What's the difference between scopes and roles, and how do you combine them in an authorization policy?#
Short answer: A scope is negotiated at token-issuance time and describes what the client application is allowed to ask the authorization server for on the user's behalf — a delegation boundary; a role (or a finer-grained claim) describes who the user is and is evaluated by the resource server when it decides whether this specific request is allowed — an authorization decision. Scopes constrain the client; roles and claims drive the actual business rule.
options.AddPolicy("CanRefundOrders", policy =>
policy.RequireClaim("scope", "orders.write")
.RequireRole("OrderManager"));Conflating the two is a common source of both over- and under-permissioned APIs. If an endpoint only checks scope=orders.write, then any user who consented to that scope through any client can call it, even a support engineer's read-mostly tool that happened to request a broad scope — the scope check alone says nothing about whether this particular user should be allowed to refund orders. Conversely, if an endpoint only checks a role claim and ignores scope, a client application that was only ever granted orders.read could still invoke a write endpoint on behalf of a user who does hold the OrderManager role, because the client-side restriction was never enforced. The policy above requires both: the token must carry a scope proving the calling client was authorized to request write access, and the user identity behind it must independently hold the role that makes refunding orders legitimate for them. Larger systems often extend this into resource-based authorization, where the check also considers the specific order — for example, whether it belongs to the caller's own tenant — rather than trusting scope and role alone.
What interviewers look for: the "client delegation boundary vs. user authorization decision" framing, and a concrete example of checking both together instead of treating them as interchangeable.
Q8 Is OAuth 2.1 a finished standard, and what does it actually change relative to OAuth 2.0?#
Short answer: No — as of today, OAuth 2.1 is still an IETF Internet-Draft (draft-ietf-oauth-v2-1) working its way through the OAuth Working Group, not a published RFC; it doesn't invent new protocol mechanics but consolidates OAuth 2.0's core grants with the security best practices the industry has already converged on, largely folding in RFC 9700 (the OAuth 2.0 Security Best Current Practice) as mandatory rather than optional guidance.
The concrete changes are specific and worth knowing individually, because interviewers will ask for them rather than accepting "it's more secure":
- PKCE is required for every authorization code grant client, public or confidential, and only the
S256challenge method is allowed —plainis removed. - The Implicit grant (
response_type=token) is removed entirely; tokens are never returned directly in a redirect fragment. - The Resource Owner Password Credentials grant is removed; an application should never collect and forward a user's actual password.
- Redirect URIs must be compared using exact string matching, closing the door on pattern- or prefix-based matching that attackers have historically abused.
- Access tokens must not be transmitted as a URI query parameter, since query strings routinely end up in server logs, browser history and referrer headers.
- Refresh tokens issued to public clients must be rotated or sender-constrained.
Because it's still a draft, treat "OAuth 2.1" in an interview or a design document as shorthand for "the current OAuth 2.0 security best practices," not as a separately implemented protocol version — there's no oauth2.1 grant type or endpoint to point a client library at. Most modern identity platforms already enforce the bulk of this list today under the OAuth 2.0 label.
What interviewers look for: the correct standards-track status (draft, not RFC), plus fluency with the specific mandatory changes rather than a vague "it tightens security."
Q9 How do you validate an OpenID Connect ID token correctly, beyond checking the signature?#
Short answer: Beyond signature verification, a compliant client must validate the issuer against the expected provider, the audience against its own client ID, the token's expiry and issued-at times, and — critically — the nonce claim against the value it generated and stored before starting the authentication request, which is what prevents an attacker from replaying a previously issued, validly signed ID token into a fresh login session.
The nonce check is the piece most homegrown OIDC integrations get wrong or skip. Because an ID token is just a signed JWT, a token issued for one login attempt is technically valid to parse and verify at any later point — nothing about the signature changes. The nonce is the client's own per-request random value, bound to the browser session before redirecting to the authorization server, and echoed back inside the returned ID token; if the value in the token doesn't match what the client stored for this session, the token belongs to a different authentication attempt and must be rejected, regardless of how valid its signature is. When the flow also returns an access token or authorization code alongside the ID token, OpenID Connect additionally defines at_hash and c_hash claims so the client can confirm those values weren't swapped in transit. Discovery makes most of this configuration-driven rather than hardcoded: a client fetches /.well-known/openid-configuration to learn the issuer, the JWKS endpoint and supported algorithms, so as long as the validation library is told the expected issuer and audience, the operational parts (key rotation, endpoint URLs) stay current automatically.
What interviewers look for: the nonce check specifically — it's the detail that separates candidates who've implemented OIDC from those who've only configured a library — plus awareness of at_hash/c_hash when tokens are combined.
Q10 Design token validation for an API gateway that must authenticate a JWT access token on every request at high throughput. What do you optimize?#
Short answer: Fetch and cache the identity provider's signing keys locally with a background refresh tied to the kid in incoming tokens, validate signature and standard claims entirely in-process with no network call on the request path, keep access tokens short-lived so a compromised token has a small blast radius instead of relying on synchronous revocation checks, and set a small, deliberate clock-skew tolerance so the gateway doesn't reject valid tokens from clients with slightly drifted clocks.
var configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
"https://login.example-tenant.com/.well-known/openid-configuration",
new OpenIdConnectConfigurationRetriever());
var config = await configManager.GetConfigurationAsync(ct);
var signingKeys = config.SigningKeys;The throughput-killing mistake is calling a token introspection endpoint synchronously on every request — that turns the gateway's request latency and availability into a direct function of the identity provider's, which is exactly the coupling a gateway is supposed to remove. Structured JWT access tokens following RFC 9068 avoid this: the gateway verifies the signature against a locally cached key set and checks iss, aud, exp and nbf with no external call at all. ConfigurationManager<T> (from Microsoft.IdentityModel.Protocols) already implements the caching and background-refresh behavior correctly, including honoring the provider's cache lifetime and retrying on the next kid it hasn't seen before it assumes a key is genuinely missing rather than just not-yet-refreshed. The trade-off worth stating explicitly: locally validated JWTs stay "valid" until they expire even if the session is terminated server-side, so gateways needing near-real-time revocation either keep access tokens short-lived and rely on refresh-time revocation, or maintain a small, fast deny-list for forced logout instead of an introspection round trip per request.
What interviewers look for: identifying the introspection-per-request anti-pattern, correct use of the JWKS caching/rotation mechanism, and a clear-eyed trade-off between local validation speed and revocation freshness.
Quick-Fire Round#
| Question | Answer |
|---|---|
| Which OAuth grant has no redirect and no user interaction? | Client credentials. |
| Which PKCE challenge method does OAuth 2.1 allow? | S256 only — plain is removed. |
| Which token should a resource API validate? | The access token, never the ID token. |
| What claim in an ID token prevents replay across login attempts? | nonce. |
| Where should a SPA store its access token under the BFF pattern? | Nowhere — the token stays server-side. |
| Is OAuth 2.1 a published RFC? | No, it's still an IETF Internet-Draft. |
| What RFC defines the JWT profile for OAuth access tokens? | RFC 9068. |
| What detects a stolen, rotated refresh token? | Reuse of an already-invalidated token in its family. |
How to Prepare#
- Implement the authorization code flow with PKCE by hand once, including generating and verifying the
S256challenge, so the mechanics aren't abstract. - Deliberately misconfigure a JWT validator (disable audience validation, allow
alg: none) against a local API and confirm you can forge a token that passes — then fix it. - Read the OAuth 2.1 draft's list of changes from OAuth 2.0 closely enough to name five of them without notes.
- Build a minimal BFF: an ASP.NET Core app that performs the code exchange server-side and forwards authenticated requests to a separate resource API on the browser's behalf.
- Be ready to explain scopes and roles with a concrete endpoint example, not just the definitions.