OAuth 2.1 and PKCE: Modern Auth for .NET APIs and SPAs
Implement OAuth 2.1 with PKCE in .NET. Covers the authorization code flow, PKCE mechanics, token introspection, scope design, client credentials for service-to-service, device flow, and common mistakes.
OAuth 2.0 vs OAuth 2.1
OAuth 2.1 (still a draft but widely adopted) consolidates best practices from OAuth 2.0:
- PKCE is required for all authorization code flows — not just public clients
- Implicit flow is removed — was always insecure
- Resource Owner Password Credentials removed — use device flow instead
- Redirect URIs must be exact matches — no wildcard matching
- Refresh tokens must be rotation-only — one-time use
Most modern identity providers (Entra ID, Auth0, Okta, Keycloak) already implement 2.1 practices.
The Authorization Code Flow with PKCE
PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks.
1. App generates a random code_verifier (43-128 chars)
2. App hashes it: code_challenge = BASE64URL(SHA256(code_verifier))
3. App sends code_challenge to the /authorize endpoint
4. User authenticates, server returns auth code
5. App sends auth code + code_verifier to /token endpoint
6. Server verifies hash(code_verifier) == code_challenge
7. Server returns access token + refresh tokenIf someone intercepts step 4's auth code, they can't get a token without the original code_verifier.
Protecting Your .NET API (Resource Server)
// Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// With Entra ID (Azure AD)
options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
options.Audience = builder.Configuration["Auth:ClientId"];
// With a custom identity server (e.g., Duende IdentityServer)
// options.Authority = "https://identity.orderflow.com";
// options.Audience = "orders-api";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
// Scope-based authorisation
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("orders.read", p => p.RequireClaim("scp", "orders.read"));
options.AddPolicy("orders.write", p => p.RequireClaim("scp", "orders.write"));
options.AddPolicy("orders.admin", p => p.RequireClaim("roles", "OrderAdmin"));
});[Authorize(Policy = "orders.read")]
[HttpGet("orders")]
public async Task<IActionResult> GetOrders(CancellationToken ct)
{ /* ... */ }
[Authorize(Policy = "orders.write")]
[HttpPost("orders")]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest req, CancellationToken ct)
{ /* ... */ }Client Credentials (Service-to-Service)
No user involved — service authenticates with its own client ID and secret.
// Service that calls another API
builder.Services.AddHttpClient<IProductService, ProductService>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["Services:Products"]);
})
.AddClientCredentialsTokenHandler(); // from Duende.AccessTokenManagement
// Configure client credentials
builder.Services.AddClientCredentials(options =>
{
var auth = builder.Configuration.GetSection("Auth");
options.Clients.Add("products-api", new ClientCredentialsClient
{
TokenEndpoint = $"{auth["Authority"]}/connect/token",
ClientId = auth["ClientId"]!,
ClientSecret = auth["ClientSecret"]!,
Scope = "products.read"
});
});// Or manually with HttpClient
public class TokenProvider
{
private readonly IHttpClientFactory _factory;
private AccessToken? _cached;
public async Task<string> GetTokenAsync(CancellationToken ct)
{
if (_cached?.IsExpired == false) return _cached.Value;
using var client = _factory.CreateClient();
var response = await client.PostAsync(
tokenEndpoint,
new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = clientId,
["client_secret"] = clientSecret,
["scope"] = "products.read"
}), ct);
var token = await response.Content.ReadFromJsonAsync<TokenResponse>(ct);
_cached = new AccessToken(token!.AccessToken, DateTime.UtcNow.AddSeconds(token.ExpiresIn - 30));
return _cached.Value;
}
}PKCE in a Blazor WASM App
// Program.cs
builder.Services.AddMsalAuthentication(options =>
{
builder.Configuration.Bind("AzureAd", options.ProviderOptions.Authentication);
options.ProviderOptions.DefaultAccessTokenScopes.Add("api://orders-api/orders.read");
options.ProviderOptions.AdditionalScopesToConsent.Add("api://orders-api/orders.write");
});MSAL automatically handles PKCE — you don't implement it manually.
Token Introspection
For opaque tokens (not JWTs), validate at the authorization server:
builder.Services.AddAuthentication()
.AddOAuth2Introspection("introspection", options =>
{
options.Authority = "https://identity.orderflow.com";
options.ClientId = "orders-api";
options.ClientSecret = builder.Configuration["Auth:IntrospectionSecret"];
});Each request calls the identity server's /introspect endpoint — adds latency, but tokens can be revoked immediately.
Scope Design
# Resource server scopes (permissions on your API)
orders.read # list and view orders
orders.write # create and update orders
orders.cancel # cancel orders (separate — often restricted)
orders.admin # admin operations (impersonation, bulk ops)
# Use minimal scopes — don't issue orders.admin to a public app
# Client credentials can only get scopes granted to the client// Check scopes programmatically
var scopes = User.FindFirst("scp")?.Value?.Split(' ') ?? Array.Empty<string>();
if (!scopes.Contains("orders.write"))
return Forbid();PKCE Flow Implementation (Manual Reference)
// Generate PKCE challenge
public static (string verifier, string challenge) GeneratePkcePair()
{
// code_verifier: 43-128 random URL-safe chars
var bytes = RandomNumberGenerator.GetBytes(32);
var verifier = Base64UrlEncoder.Encode(bytes);
// code_challenge: S256 = BASE64URL(SHA256(verifier))
var hash = SHA256.HashData(Encoding.ASCII.GetBytes(verifier));
var challenge = Base64UrlEncoder.Encode(hash);
return (verifier, challenge);
}
// Authorization URL
var (verifier, challenge) = GeneratePkcePair();
// Store verifier in session/cookie — needed for token exchange
var authUrl = $"{authority}/authorize" +
$"?response_type=code" +
$"&client_id={clientId}" +
$"&redirect_uri={Uri.EscapeDataString(redirectUri)}" +
$"&scope=openid profile email orders.read" +
$"&code_challenge={challenge}" +
$"&code_challenge_method=S256" +
$"&state={Guid.NewGuid()}";
// Token exchange (callback)
var tokenResponse = await httpClient.PostAsync(
$"{authority}/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "authorization_code",
["code"] = authCode,
["redirect_uri"] = redirectUri,
["client_id"] = clientId,
["code_verifier"] = verifier // the original, not the hash
}));Common Mistakes
| Mistake | Risk | Fix | |---|---|---| | Storing access tokens in localStorage | XSS can steal them | Use httpOnly cookies or in-memory | | Long-lived access tokens | Window for token theft | 15-minute tokens + refresh rotation | | No PKCE on public clients | Auth code interception | PKCE required for all SPAs and mobile apps | | Implicit flow | Token in URL fragment (logged) | Always use auth code + PKCE | | Wildcard redirect URIs | Open redirect attacks | Exact URI matching only | | Logging Authorization headers | Tokens in logs | Scrub Authorization headers from logs |
Interview Questions
Q: What is PKCE and why is it needed even for confidential clients in OAuth 2.1? PKCE prevents authorization code interception — if an attacker intercepts the code in the redirect, they can't exchange it without the original code_verifier. OAuth 2.1 requires it for all flows because even confidential clients can have their redirect intercepted in some environments. It costs nothing to implement and eliminates a whole class of attacks.
Q: What is the difference between the authorization code flow and client credentials? Auth code flow involves a user — the user authenticates and grants the app permission to act on their behalf. Client credentials involves no user — the service authenticates with its own ID and secret to call another service. Use auth code for user-facing apps; use client credentials for service-to-service.
Q: Why was the implicit flow removed in OAuth 2.1? The implicit flow returns tokens in the URL fragment — which gets logged in browser history, server logs, and HTTP referer headers. It also has no mechanism to verify the token was issued to the right client. PKCE-protected auth code flow achieves the same goal (no client secret needed) with none of the risks.
Q: How do you invalidate a JWT before its expiry? JWTs are stateless — validation doesn't require a DB call, but revocation does. Options: maintain a token blocklist (Redis, checked on every request), use short-lived tokens (accept the ~15 minute window), use opaque tokens with introspection (every request hits the auth server), or use refresh token revocation (revoke the refresh token — new access tokens can't be issued).
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.