OAuth 2.0 and OpenID Connect — Identity Reference

Flows, tokens, validation and the failure modes that cause real incidents: PKCE, audience checks, refresh rotation, the BFF pattern and the misconfigurations reviewers look for.

Scope: OAuth 2.0 · OIDC 1.0 · PKCE · JWT / JWKS · ASP.NET Core 8 · Entra ID / Keycloak / Auth0

01OAuth 2.0 and OIDC: who does what

OAuth 2.0 is a delegation framework: it lets a client obtain limited access to a resource on behalf of a user (or itself) without ever seeing the password. OpenID Connect is a thin identity layer on top — it adds an ID token that says who the user is.

The four roles are worth naming precisely, because most misdesigns come from confusing them.

RoleIn practice
Resource ownerThe user, or the service that owns the data
ClientYour SPA, mobile app, desktop app or backend service
Authorization serverEntra ID, Keycloak, Auth0, Okta, IdentityServer — issues tokens
Resource serverYour API — validates the access token, never issues one
  • Your API is a resource server: it validates tokens, it does not issue them or run login flows.
  • If you find yourself validating an ID token inside an API, the design is wrong — that token was meant for the client.
  • Say 'authorization server' rather than 'the IdP' when precision matters; interviewers notice.
OAuth 2.0  ->  authorization: may this client call this API?
OIDC       ->  authentication : who is the user?

ID token      -> for the client. "This is Ana, authenticated at 10:04."
                 audience = client_id. Never send it to an API.
Access token  -> for the resource server. audience = the API.
                 The API validates it and reads scopes/roles from it.
Refresh token -> for the client only, to obtain new access tokens.

02Choosing the flow

There are three flows that matter today and two that should not be used for new work. The choice is decided by the client type, not by preference.

Authorization code with PKCE is the default for anything involving a user — web app, SPA, mobile, desktop.

FlowUse it forNotes
Authorization code + PKCEWeb apps, SPAs, mobile, desktopDefault. PKCE is mandatory for public clients
Client credentialsService-to-service, no userClient authenticates with a secret or certificate
Device codeCLI tools, TVs, input-limited devicesUser completes login on another device
Implicit—Deprecated: tokens in the URL fragment
Password (ROPC)—Deprecated: the app handles the password, breaks MFA
  • A SPA is a public client: anything in the bundle is public, so no client secret.
  • Implicit existed for browsers before CORS and PKCE; there is no reason to choose it now.
  • ROPC exists for legacy migration only; it defeats MFA and conditional access.
// Which flow? Two questions.
// 1. Is there a user in front of the screen?
//    yes -> authorization code + PKCE
//    no  -> client credentials
// 2. Can the client keep a secret? (server-side)
//    yes -> confidential client, secret or certificate
//    no  -> public client, PKCE required, no secret shipped

03Authorization code + PKCE, step by step

Two requests to the authorization server and one to the token endpoint. PKCE binds the code to the client that started the flow, so a stolen code is useless on its own.

Being able to narrate this sequence is usually the actual interview question.

  • `state` defends against CSRF on the callback; `nonce` defends against token replay in the ID token.
  • The `code_verifier` never leaves the client; only its hash travelled in the URL.
  • The token request happens over TLS, server-to-server — never from the browser with a secret.
1. Client generates:  code_verifier (43-128 chars, random)
                      code_challenge = BASE64URL(SHA256(code_verifier))

2. Browser redirect:
   GET /authorize
     ?response_type=code
     &client_id=orders-web
     &redirect_uri=https://app.example.com/callback
     &scope=openid profile orders.read
     &state=<random, verified on return>
     &nonce=<random, verified inside the ID token>
     &code_challenge=<challenge>&code_challenge_method=S256

3. Authorization server authenticates the user, redirects back with ?code=...&state=...

4. Client exchanges the code, server-to-server:
   POST /token
     grant_type=authorization_code&code=<code>
     &redirect_uri=...&client_id=orders-web
     &code_verifier=<the original verifier>

5. Response: access_token, id_token, refresh_token (optional), expires_in

04Tokens: what is inside, and how long it lives

A JWT is a signed claim set, not an encrypted one: anyone holding it can read it. Signature verification proves it was issued by the authorization server and not modified.

Short-lived access tokens plus refresh tokens is the standard: minutes to an hour for access, days for refresh, with rotation.

  • Never trust a JWT you did not verify — decoding is not validating.
  • `sub` identifies the user; `aud` must be your API; `iss` must be your authorization server.
  • Keep access tokens out of logs, URLs and localStorage.
{
  "iss": "https://login.example.com/",        // issuer  - validate
  "aud": "orders-api",                       // audience - validate
  "sub": "8f14e45f-...",                     // user id - stable
  "scp": "orders.read orders.write",         // scopes (Entra: scp, others: scope)
  "roles": ["OrderManager"],                 // app roles, if configured
  "exp": 1747051200, "nbf": 1747047600,      // lifetime - validate with small skew
  "iat": 1747047600,
  "tid": "0e9f2fce-..."                      // tenant, for multi-tenant APIs
}

// Header: {"alg":"RS256","kid":"k-2025-05"} -> RS256, key selected by kid

05Validating tokens in ASP.NET Core

Use the framework: JWT bearer middleware with OIDC discovery fetches the signing keys and rotates them automatically. What you must configure is issuer, audience and clock skew.

`MapInboundClaims = false` keeps claim names as issued (`sub`, `scp`) instead of the legacy SOAP-era mapping.

  • `Authority` handles key rotation through the discovery document — do not hard-code signing keys.
  • Default `ClockSkew` is five minutes; shortening it makes expiry failures visible sooner.
  • Order matters: `UseAuthentication` before `UseAuthorization`, always.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.Authority = "https://login.example.com";     // OIDC discovery + JWKS
        o.Audience = "orders-api";
        o.MapInboundClaims = false;                    // keep "sub", "scp" as issued
        o.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ClockSkew = TimeSpan.FromSeconds(30),
            NameClaimType = "sub"
        };
    });

app.UseAuthentication();
app.UseAuthorization();

06Scopes, roles and policies: the authorization model

Scopes describe what a client may do on behalf of a user; app roles describe what the user is allowed to do. In an enterprise API you usually need both.

Policies are where the two meet, and where the checks belong — not scattered as `if` statements in handlers.

  • Check authorization on the server for every endpoint, including the ones the UI hides.
  • A scope answers 'may this app call this API'; a role answers 'may this person do this action'.
  • Write policies so they can be unit-tested without an HTTP request.
builder.Services.AddAuthorization(o =>
{
    o.AddPolicy("orders:read", p => p
        .RequireClaim("scp", "orders.read")
        .AddRequirements(new MinimumTenantRequirement()));

    o.AddPolicy("orders:approve", p => p
        .RequireAuthenticatedUser()
        .RequireRole("OrderManager")
        .RequireClaim("scp", "orders.write"));
});

[Authorize(Policy = "orders:approve")]
[HttpPost("orders/{id:guid}/approve")]
public async Task<IActionResult> Approve(Guid id) { ... }

07Refresh tokens: rotation and reuse detection

A refresh token is a long-lived credential. Rotation issues a new one on each use, and reuse of an old one is treated as theft: revoke the whole family.

For a BFF or a confidential client the refresh token lives server-side; a SPA should not handle one at all.

  • Store a hash, never the token itself; a database leak should not hand over sessions.
  • Rotate on every use and revoke the family on reuse — that is what detects a stolen token.
  • Bind the refresh token to the client and, where supported, to the device.
// Server-side token store, minimal shape
public sealed record RefreshToken(string Hashed, string FamilyId, DateTime ExpiresAt, bool Revoked);

async Task<TokenResponse> RefreshAsync(string presented)
{
    var stored = await _store.FindAsync(Hash(presented))
        ?? throw new SecurityTokenException("Unknown refresh token");

    if (stored.Revoked)
    {
        await _store.RevokeFamilyAsync(stored.FamilyId);    // reuse detected
        throw new SecurityTokenException("Refresh token reuse");
    }

    var next = await _idp.RefreshAsync(presented);
    await _store.RevokeAsync(stored);
    await _store.SaveAsync(Hash(next.RefreshToken), stored.FamilyId, next.ExpiresAt);
    return next;
}

08Service-to-service with client credentials

No user involved: the service authenticates as itself with a client id plus secret or certificate, and receives an access token for the target API.

Certificates or workload identity beat secrets — a secret in configuration is a secret in a repository eventually.

  • The scope for app-only calls is usually `api://{api-id}/.default`.
  • Cache tokens in memory until expiry; requesting one per call is a latency and throttling problem.
  • Never pass the user's token through to another API — that is token passthrough and breaks the audience check.
// Program.cs - .NET 8 with Microsoft.Identity.Web
builder.Services.AddMicrosoftIdentityWebAppAuthentication(builder.Configuration)
    .EnableTokenAcquisitionToCallDownstreamApi()
    .AddInMemoryTokenCaches();

// Typed client: the handler attaches the token, the caller never sees it
builder.Services.AddHttpClient<IPricingClient, PricingClient>(c =>
    c.BaseAddress = new Uri("https://pricing.internal/"));

public sealed class PricingClient(HttpClient http, ITokenAcquisition tokens)
{
    public async Task<Price?> GetAsync(string sku)
    {
        var token = await tokens.GetAccessTokenForAppAsync("api://pricing/.default");
        http.DefaultRequestHeaders.Authorization = new("Bearer", token);
        return await http.GetFromJsonAsync<Price>($"/prices/{sku}");
    }
}

09SPAs and the BFF pattern

A browser cannot keep a secret and `localStorage` is readable by any script on the page. The modern answer for enterprise SPAs is a backend-for-frontend: the token stays on the server, the browser gets only a session cookie.

If you must keep tokens in the SPA, use the authorization code flow with PKCE in memory, short lifetimes, and accept the XSS risk explicitly.

  • A cookie-authenticated endpoint needs CSRF protection: SameSite alone is not a complete answer.
  • `SameSite=None` (needed for cross-site) reopens CSRF; keep the BFF on the same origin as the SPA.
  • This is exactly the shape most enterprise Angular + .NET systems converge on.
Browser  <->  BFF (same origin, cookie)  <->  API (bearer token)

BFF responsibilities:
  - OIDC login/logout, holds access + refresh tokens server-side
  - sets an HttpOnly, Secure, SameSite=Lax session cookie
  - proxies /api/* to the real API, attaching the bearer token
  - CSRF protection on state-changing requests
  - silent renewal with the refresh token, invisible to the SPA

Why: XSS can still act as the user, but it cannot exfiltrate a token
that was never in the browser, and revocation happens server-side.

10Logout and session lifetime

OIDC defines RP-initiated logout: the client redirects to the end-session endpoint so the authorization server also clears its session. Without it, 'log out' only clears your app and the next login is silent.

Session lifetime is a product decision expressed in three numbers: access token lifetime, refresh token lifetime, and absolute session lifetime.

  • `post_logout_redirect_uri` must be registered; otherwise the authorization server refuses to redirect.
  • Clearing the local session without the end-session call leaves the IdP session alive — the classic 'I logged out and came back logged in'.
  • For a SPA, silent renew through an iframe is being replaced by refresh tokens or the BFF for good reasons (third-party cookie blocking).
# RP-initiated logout
GET https://login.example.com/logout
  ?id_token_hint=<id_token>
  &post_logout_redirect_uri=https://app.example.com/signed-out
  &client_id=orders-web

# .NET: clear the local cookie too, always
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Redirect(logoutUrl);

11The misconfigurations that get flagged in review

Almost every real-world OAuth incident is one of these. Knowing them is what separates 'I used a library' from 'I understand the protocol'.

Each has a concrete symptom you can describe.

MistakeConsequenceCorrect approach
No audience validationA token issued for another API is acceptedValidate `aud` against your own API id
No issuer validationTokens from another tenant acceptedValidate `iss` (and `tid` for multi-tenant)
Token passthroughYour API forwards the user token downstreamGet a token for the downstream audience
Secrets in a SPAAnyone can impersonate the clientPublic client + PKCE, no secret
Wildcard redirect URIAuthorization code theft via open redirectExact match, registered per environment
No `state`/`nonce`CSRF and token replayRandom values, verified on return
Tokens in localStorageExfiltration by any XSSBFF cookie, or memory only
Accepting `alg: none`Unsigned tokens acceptedPin the expected algorithm in the library
  • Write the audience down: it is the single most-missed check.
  • Multi-tenant APIs must validate the tenant, not just the signature.
  • A 401 in staging with a valid-looking token is usually an audience or issuer mismatch.
// What a good review looks like at the middleware level
o.Authority = "https://login.example.com";   // iss + JWKS from discovery
o.Audience  = "orders-api";                  // aud must be us
o.RequireHttpsMetadata = true;
o.MapInboundClaims = false;
o.TokenValidationParameters.ValidAlgorithms = ["RS256"];   // no alg confusion
o.TokenValidationParameters.ClockSkew = TimeSpan.FromSeconds(30);

12Debugging a token problem

The failure modes are few and mechanical: wrong audience, wrong issuer, expired clock, key not found, or claims mapped away by the framework.

A repeatable checklist beats guessing.

  • `jwt.ms` or `jwt.io` to read; never paste production tokens into a public site.
  • Log the validation failure reason (`OnAuthenticationFailed`), not just the 401.
  • An integration test with a locally generated token pair catches most regressions before deployment.
1. Decode the token (payload only) and read: iss, aud, exp, scp/roles, tid
2. Compare aud with the API's expected audience, exactly
3. Compare iss with Authority, including the trailing slash
4. Check exp/nbf against the server clock, not your laptop's
5. If signature fails: does the kid exist in {authority}/.well-known/openid-configuration -> jwks_uri?
6. If claims are missing: MapInboundClaims and the mapping table renamed them
7. Still 401? Enable PII logging in development, never in production

13When OIDC is not the answer

Comparable technologies exist for good reasons. Choosing wrongly is as telling as choosing badly.

OIDC for user identity in modern apps; SAML when the enterprise IdP only speaks SAML; mTLS and API keys for machine-to-machine inside a trusted network.

TechnologyGood fitCost
OIDC / OAuth 2.0Modern web, mobile, API ecosystems, SSOToken lifetimes and client configuration
SAML 2.0Enterprise SSO with legacy IdPs, regulated sectorsXML signatures, verbose, awkward for APIs
API keysSimple server-to-server in a closed environmentNo expiry by default, no scopes, hard to rotate
mTLSStrong service identity inside a meshCertificate lifecycle management
Windows / KerberosOn-premises Windows domainDoes not cross cloud boundaries
  • Interviewers ask this to hear a trade-off, not a preference.
  • OIDC can be bridged to SAML at the authorization server; that is often the pragmatic answer in banks.
  • Any answer should mention rotation and revocation, whichever technology you pick.
// Decision shortcut
// Users + modern app        -> OIDC
// Enterprise IdP, no OIDC   -> SAML 2.0 (bridge it if you can)
// Service inside a mesh     -> mTLS, or workload identity
// Public API, third parties -> OAuth 2.0 client credentials per partner
// Internal script, one team -> API key only if it can be rotated and scoped
← Back to the knowledge base