Waqas Ahmad — Software Architect & Technical Consultant - Available USA, Europe, Global

Waqas Ahmad — Software Architect & Technical Consultant

Specializing in

Distributed Systems

.NET ArchitectureCloud-Native ArchitectureAzure Cloud EngineeringAPI ArchitectureMicroservices ArchitectureEvent-Driven ArchitectureDatabase Design & Optimization

👋 Hi, I'm Waqas — a Software Architect and Technical Consultant specializing in .NET, Azure, microservices, and API-first system design..
I help companies build reliable, maintainable, and high-performance backend platforms that scale.

Experienced across engineering ecosystems shaped by Microsoft, the Cloud Native Computing Foundation, and the Apache Software Foundation.

Available for remote consulting (USA, Europe, Global) — flexible across EST, PST, GMT & CET.

services
Article

OAuth2 and OpenID Connect in .NET: In-Depth

OAuth2, OpenID Connect, JWT validation, and securing .NET APIs and SPAs.

services
Read the article

Introduction

This guidance is relevant when the topic of this article applies to your system or design choices; it breaks down when constraints or context differ. I’ve applied it in real projects and refined the takeaways over time (as of 2026).

OAuth2 and OpenID Connect (OIDC) are the standards for modern auth: OAuth2 handles authorisation (apps accessing resources on behalf of users), OIDC adds authentication (who the user is). This article covers OAuth2 and OIDC in depth—flows, JWT validation in ASP.NET Core, securing APIs and SPAs, and common pitfalls. For architects and tech leads, getting flows and token validation right (Authority, Audience, PKCE for SPAs) is essential to avoid security gaps.

For a deeper overview of this topic, explore the full .NET Architecture guide.

Decision Context

  • System scale: APIs and SPAs (or native apps) that need to authenticate users or call APIs on behalf of users or services. Applies when you’re adding or refining auth (OAuth2/OIDC) in .NET.
  • Team size: Backend and front-end; someone must own token validation, scopes, and secure storage. Works when the team can configure an identity provider (Azure AD, Auth0, etc.) and validate JWTs correctly.
  • Time / budget pressure: Fits when you have an IdP and can use Authorization Code + PKCE for SPAs; breaks down when you try to invent your own auth or store secrets in the client.
  • Technical constraints: ASP.NET Core; OAuth2/OIDC; JWT validation; Azure AD, Auth0, or similar IdP. Assumes you use HTTPS and don’t log or expose tokens.
  • Non-goals: This article does not optimise for legacy or custom auth schemes; it focuses on OAuth2 and OpenID Connect in .NET with standard flows.

What is OAuth2?

OAuth2 is an authorisation framework. It lets a client application obtain limited access to a resource (API) on behalf of a user, without the user sharing their password.

Key concepts:

  • Resource Owner: The user who owns the data
  • Client: The application requesting access
  • Authorization Server: Issues tokens (e.g. Azure AD, Auth0, IdentityServer)
  • Resource Server: The API that accepts tokens

OAuth2 does not tell you who the user is—only that they granted permission. That is where OpenID Connect comes in.

What is OpenID Connect?

OpenID Connect (OIDC) is a layer on top of OAuth2 that adds authentication. It introduces:

  • ID Token: A JWT containing user identity claims (sub, name, email)
  • UserInfo Endpoint: Returns user profile data
  • Standard Claims: sub (subject), iss (issuer), aud (audience), exp (expiration)

Use OIDC when you need to know who the user is, not just that they are authorised.

OAuth2 flows

Flow Use case Client type
Authorization Code User login (SPA, mobile, web) Confidential or public
Authorization Code + PKCE Public clients (SPA, mobile) Public
Client Credentials Machine-to-machine (no user) Confidential
Implicit Legacy; avoid Public
Device Code Devices without browser (TV, CLI) Public

Authorization Code Flow

Used when a user logs in via a browser:

  1. App redirects user to Authorization Server
  2. User logs in and consents
  3. Authorization Server redirects back with a code
  4. App exchanges code for access token (and optionally refresh token)
GET /authorize?
  response_type=code&
  client_id=myapp&
  redirect_uri=https://myapp.com/callback&
  scope=openid profile api.read&
  state=xyz

POST /token
  grant_type=authorization_code&
  code=abc123&
  redirect_uri=https://myapp.com/callback&
  client_id=myapp&
  client_secret=secret

Authorization Code + PKCE

For public clients (SPAs, mobile apps) that cannot securely store a client secret:

  1. App generates a code_verifier (random string) and code_challenge (hash of verifier)
  2. App sends code_challenge in the authorize request
  3. After receiving code, app sends code_verifier in the token request
  4. Authorization Server verifies the challenge
// Generate PKCE values
var codeVerifier = GenerateRandomString(64);
var codeChallenge = Base64Url(SHA256(codeVerifier));

// Authorize request includes code_challenge
// Token request includes code_verifier

Client Credentials Flow

For machine-to-machine (no user involved):

POST /token
  grant_type=client_credentials&
  client_id=backend-service&
  client_secret=secret&
  scope=api.read api.write

Tokens: access, ID, refresh

Token Purpose Lifetime
Access Token Authorise API calls Short (minutes to hours)
ID Token Authenticate user (identity) Short
Refresh Token Get new access token without login Long (days to weeks)

Access Token is sent to the API in the Authorization: Bearer header. The API validates it.

ID Token is for the client application only. It contains user identity claims. Do not send it to APIs.

Refresh Token is stored securely (httpOnly cookie or secure storage). Use it to obtain new access tokens.

JWT validation in ASP.NET Core

JWT (JSON Web Token) is the common format for access and ID tokens. ASP.NET Core validates JWTs using the Microsoft.AspNetCore.Authentication.JwtBearer package.

// Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://login.microsoftonline.com/{tenant}/v2.0";
        options.Audience = "api://my-api-client-id";
        
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true
        };
    });

builder.Services.AddAuthorization();

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

What happens:

  1. JWT Bearer middleware extracts token from Authorization: Bearer header
  2. Fetches signing keys from Authority’s JWKS endpoint
  3. Validates signature, issuer, audience, expiration
  4. Populates HttpContext.User with claims

Scopes and claims

Scopes define what the token can access. The client requests scopes; the Authorization Server includes them in the token.

{
  "aud": "api://my-api",
  "iss": "https://login.microsoftonline.com/{tenant}/v2.0",
  "sub": "user-id",
  "scp": "api.read api.write",
  "exp": 1700000000
}

Validate scopes in .NET:

// Policy-based authorization
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("ReadAccess", policy =>
        policy.RequireClaim("scp", "api.read"));
    
    options.AddPolicy("WriteAccess", policy =>
        policy.RequireClaim("scp", "api.write"));
});

// Controller
[Authorize(Policy = "WriteAccess")]
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
    // Only callers with api.write scope can reach here
}

Securing APIs

1. Always validate tokens. Use JWT Bearer middleware with correct Authority and Audience.

2. Validate scopes. Do not accept any valid token—check that it has the required scopes.

3. Use HTTPS. Tokens are bearer tokens; anyone with the token can use it.

4. Set short token lifetimes. Minutes to hours for access tokens. Use refresh tokens for longer sessions.

5. Never log tokens. Access tokens in logs are a security risk.

[ApiController]
[Route("api/[controller]")]
[Authorize]
public class OrdersController : ControllerBase
{
    [HttpGet]
    [Authorize(Policy = "ReadAccess")]
    public async Task<IActionResult> GetOrders()
    {
        var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        // Return only this user's orders
    }
}

Securing SPAs

SPAs are public clients—they cannot securely store secrets. Best practices:

1. Use Authorization Code + PKCE. No client secret required.

2. Store tokens in memory or secure cookie. Never in localStorage (XSS vulnerable).

3. Use silent refresh or refresh tokens in httpOnly cookie. Avoid prompting user repeatedly.

4. Backend For Frontend (BFF) pattern. Keep tokens server-side; SPA uses session cookie.

// BFF pattern: proxy API calls, attach token server-side
app.MapGet("/api/orders", async (HttpContext context, IHttpClientFactory factory) =>
{
    var accessToken = await context.GetTokenAsync("access_token");
    var client = factory.CreateClient();
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
    return await client.GetFromJsonAsync<Order[]>("https://api.example.com/orders");
});

Enterprise best practices

1. Use Azure AD or established IdP. Do not roll your own authentication.

2. Validate all claims. Issuer, audience, expiration, signature. Do not skip validation.

3. Use PKCE for all public clients. Required for SPAs and mobile apps.

4. Store refresh tokens securely. HttpOnly cookie or secure server-side storage.

5. Implement token revocation. For sensitive actions, check token validity against a revocation list.

6. Use short-lived access tokens. 5-60 minutes. Refresh tokens extend the session.

7. Monitor for anomalies. Log authentication events; alert on unusual patterns.

8. Rotate client secrets. Regularly rotate secrets for confidential clients.

Common issues

Issue Cause Fix
401 Unauthorized Wrong audience or issuer Match Authority and Audience exactly
Token expired Lifetime exceeded Use refresh token; reduce access token lifetime
Invalid signature Wrong signing key Ensure JWKS endpoint is correct
Missing scope Token lacks required scope Client must request scope; API must validate
CORS errors Cross-origin blocked Configure CORS for your SPA domain
Token in URL Query string leaked in logs Use Authorization header only

You can also explore more patterns in the .NET Architecture resource page.

Summary

OAuth2 handles authorisation; OpenID Connect adds authentication—use Authorization Code + PKCE for user login and Client Credentials for machine-to-machine, and validate JWTs with correct Authority, Audience, and scopes. Skipping audience or scope checks or storing tokens insecurely (e.g. localStorage for SPAs) creates security gaps; use httpOnly cookies or backend session where possible and never log tokens. Next, configure your IdP and ASP.NET Core with AddAuthentication(JwtBearer), set Authority and Audience, then add PKCE for SPAs and secure token storage.

Position & Rationale

I use Authorization Code + PKCE for SPAs and native apps—no client secret in the client; the IdP issues tokens after user consent. I use Client Credentials for server-to-server or background jobs when there’s no user. I validate JWTs with the correct Authority (issuer), Audience, and signing keys (JWKS); I never skip audience or scope checks. I store tokens in memory or secure storage (e.g. httpOnly cookie for web); I never put access or refresh tokens in localStorage for SPAs if I can avoid it (cookie or backend session is safer). I avoid implicit flow and resource-owner password flow for new apps; they’re deprecated or insecure. I don’t log tokens or put them in URLs.

Trade-Offs & Failure Modes

OAuth2/OIDC adds dependency on an IdP and correct validation; you gain standard, delegatable auth. PKCE adds a step in the flow; you gain security for public clients. JWT validation must be strict (issuer, audience, scope, expiry); wrong config leads to broken auth or over-privilege. Failure modes: wrong audience or scope validation (API accepts tokens meant for another app); tokens in URL or logs (leak); client secret in SPA (never do that); skipping PKCE for public clients.

What Most Guides Miss

Most guides show “add JWT bearer” but don’t stress audience validation—if you don’t validate audience, any token from the same IdP can call your API. Scope validation is often omitted; the API must check that the token has the scope for the operation. Token storage in SPAs (localStorage vs cookie vs backend session) has security trade-offs; many tutorials use localStorage without mentioning refresh token rotation and XSS. Refresh token handling (where to store, when to rotate) is underplayed.

Decision Framework

  • If user login (SPA or native) → Authorization Code + PKCE; no client secret in the client.
  • If server-to-server or daemon → Client Credentials; store client secret server-side only.
  • For API → Validate JWT: Authority, Audience, scopes; use JWKS from the IdP.
  • For tokens → Store in memory or secure cookie; never in URL or logs; use HTTPS.
  • For SPAs → Prefer backend session or secure cookie for tokens; if using localStorage, understand XSS and refresh rotation.

Key Takeaways

  • OAuth2 = authorisation; OIDC = authentication (ID token, user info). Use Authorization Code + PKCE for SPAs/native; Client Credentials for machine-to-machine.
  • Validate JWT: issuer, audience, scopes, expiry; use JWKS. Never skip audience.
  • Store tokens securely; never in URL or logs; never client secret in the client.
  • Avoid implicit and resource-owner password flows for new apps.

When I Would Use This Again — and When I Wouldn’t

I’d use OAuth2/OIDC again for any API or SPA that needs user or service auth—standard flows and JWT validation with a proper IdP. I’d use PKCE for every public client. I wouldn’t implement my own token issuance or store client secrets in the front end. I also wouldn’t deploy without validating audience and scopes on the API; otherwise tokens from other apps can be accepted.

services
Frequently Asked Questions

Frequently Asked Questions

What is OAuth2?

OAuth2 is an authorisation framework that lets apps access resources on behalf of users without sharing passwords. It issues access tokens.

What is OpenID Connect?

OIDC extends OAuth2 for authentication. It adds ID tokens containing user identity claims (sub, name, email).

What is JWT?

JWT (JSON Web Token) is a signed token format containing claims. Validated by the API using the issuer’s public key.

What is the difference between access token and ID token?

Access token authorises API calls. ID token contains user identity—use it in your app, not for API calls.

When should I use Authorization Code vs Client Credentials?

Authorization Code for user login (SPA, mobile, web). Client Credentials for service-to-service (no user).

What is PKCE?

PKCE (Proof Key for Code Exchange) secures the Authorization Code flow for public clients that cannot store a secret.

Where should I store tokens in a SPA?

In memory or httpOnly cookie. Never in localStorage (vulnerable to XSS).

How do I validate scopes in .NET?

Use policy-based authorization: options.AddPolicy("ReadAccess", policy => policy.RequireClaim("scp", "api.read")).

What is a refresh token?

A long-lived token used to obtain new access tokens without re-prompting the user.

How do I handle token expiration?

Use refresh tokens to get new access tokens silently. Implement token refresh in your client.

What is the BFF pattern?

Backend For Frontend keeps tokens server-side. The SPA uses a session cookie; the BFF proxies API calls with the token.

How do I revoke tokens?

Implement a token revocation endpoint or use short-lived tokens. For critical actions, check a revocation list.

What Authority should I use for Azure AD?

https://login.microsoftonline.com/{tenant}/v2.0 for v2.0 endpoint. Use your tenant ID or “common” for multi-tenant.

What is the difference between issuer and audience?

Issuer is who issued the token (the IdP). Audience is who the token is intended for (your API).

How do I debug JWT issues?

Decode the token at jwt.io (never in production with real tokens). Check issuer, audience, expiration, and scopes.

services
Related Guides & Resources

services
Related services