OAuth 2.0 & OpenID Connect — Let Others Handle Auth
Stop building login screens. Delegate authentication to Google, Azure AD, or any OIDC provider. Understand OAuth flows, wire up ASP.NET Core middleware, and protect APIs with external JWTs.
OAuth 2.0 Is Not Authentication
OAuth 2.0 is an authorization protocol — it lets an app request access to resources on behalf of a user. OpenID Connect (OIDC) is an identity layer on top of OAuth 2.0 that adds authentication: you get a signed id_token telling you who the user is.
Two flows matter in practice:
| Flow | Use When | |---|---| | Authorization Code + PKCE | User-facing apps (SPAs, mobile, web) | | Client Credentials | Server-to-server, no user involved |
Authorization Code Flow With PKCE
The browser never sees the client_secret. PKCE replaces it with a one-time code_verifier/code_challenge pair.
1. App generates code_verifier (random 43-128 char string)
2. App computes code_challenge = BASE64URL(SHA256(code_verifier))
3. Redirect user to:
https://auth.example.com/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://app.example.com/callback
&scope=openid profile email
&code_challenge=
&code_challenge_method=S256
4. User authenticates at the provider
5. Provider redirects back with ?code=AUTH_CODE
6. App POSTs to token endpoint with code + code_verifier
7. App receives access_token + id_token Client Credentials Flow
No user, no browser. Service A calls Service B using a token issued directly to the app.
// Fetching a token with HttpClient — no user context
public class TokenService
{
private readonly HttpClient _http;
private readonly IConfiguration _config;
public TokenService(HttpClient http, IConfiguration config)
{
_http = http;
_config = config;
}
public async Task<string> GetAccessTokenAsync()
{
var values = new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = _config["Auth:ClientId"]!,
["client_secret"] = _config["Auth:ClientSecret"]!,
["scope"] = "api://my-service/.default"
};
var response = await _http.PostAsync(
_config["Auth:TokenEndpoint"],
new FormUrlEncodedContent(values));
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<TokenResponse>();
return json!.AccessToken;
}
}
public record TokenResponse([property: JsonPropertyName("access_token")] string AccessToken);AddAuthentication + AddOpenIdConnect
Wire up OIDC in an ASP.NET Core web app (cookie + OIDC combo):
// Program.cs
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/logout";
})
.AddOpenIdConnect(options =>
{
options.Authority = builder.Configuration["Auth:Authority"]; // e.g. https://login.microsoftonline.com/{tenantId}/v2.0
options.ClientId = builder.Configuration["Auth:ClientId"];
options.ClientSecret = builder.Configuration["Auth:ClientSecret"];
options.ResponseType = OpenIdConnectResponseType.Code; // Authorization Code flow
options.UsePkce = true;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.SaveTokens = true; // persists tokens in the auth cookie
options.GetClaimsFromUserInfoEndpoint = true;
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
RoleClaimType = "roles"
};
});
app.UseAuthentication();
app.UseAuthorization();Protecting an API With External JWTs
When your API is a resource server (not the OIDC client), it only needs to validate incoming Bearer tokens:
// Program.cs — API that accepts tokens from Azure AD
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// Authority publishes the JWKS (public keys) at /.well-known/openid-configuration
options.Authority = "https://login.microsoftonline.com/{tenantId}/v2.0";
options.Audience = "api://my-api-client-id";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true
};
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = ctx =>
{
// Log but don't leak exception detail to caller
var logger = ctx.HttpContext.RequestServices
.GetRequiredService<ILogger<Program>>();
logger.LogWarning("JWT validation failed: {Msg}", ctx.Exception.Message);
return Task.CompletedTask;
}
};
});The middleware downloads the JWKS from the authority on first request and caches it. Signature validation is automatic.
Scope vs Claim vs Role
| Concept | Lives In | Purpose |
|---|---|---|
| scope | access_token | What the token is allowed to do (e.g. read:orders) |
| claim | id_token / access_token | Facts about the user (e.g. email, sub, department) |
| role | access_token custom claim | Group membership for coarse authorization |
Check scopes in a policy:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ReadOrders", policy =>
policy.RequireClaim("scp", "read:orders", "orders.read"));
options.AddPolicy("WriteOrders", policy =>
policy.RequireClaim("scp", "write:orders", "orders.write"));
});Using Google as the Identity Provider
// NuGet: Microsoft.AspNetCore.Authentication.Google
builder.Services
.AddAuthentication(...)
.AddCookie()
.AddGoogle(options =>
{
options.ClientId = builder.Configuration["Google:ClientId"]!;
options.ClientSecret = builder.Configuration["Google:ClientSecret"]!;
options.Scope.Add("email");
options.Scope.Add("profile");
// Map Google claims to standard .NET claims
options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
options.ClaimActions.MapJsonKey(ClaimTypes.GivenName, "given_name");
});Validating Claims After Authentication
Use OnTokenValidated to enrich or reject the principal early:
options.Events = new OpenIdConnectEvents
{
OnTokenValidated = async ctx =>
{
var email = ctx.Principal!.FindFirstValue(ClaimTypes.Email);
// Only allow company domain
if (email is null || !email.EndsWith("@mycompany.com"))
{
ctx.Fail("Unauthorized domain.");
return;
}
// Optionally add claims from your own DB
var userService = ctx.HttpContext.RequestServices
.GetRequiredService<IUserService>();
var appClaims = await userService.GetClaimsAsync(email);
var appIdentity = new ClaimsIdentity(appClaims);
ctx.Principal!.AddIdentity(appIdentity);
}
};Getting the Access Token in a Controller
When SaveTokens = true, tokens are stored in the cookie:
[Authorize]
[HttpGet("call-downstream")]
public async Task<IActionResult> CallDownstream()
{
var accessToken = await HttpContext.GetTokenAsync("access_token");
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", accessToken);
var result = await _httpClient.GetStringAsync("https://api.downstream.com/data");
return Ok(result);
}Key Takeaways
- OAuth 2.0 = authorization; OIDC = authentication built on top of it
- Use Authorization Code + PKCE for any user-facing flow
- Use Client Credentials for service-to-service calls — no user involved
- Resource server APIs only need
AddJwtBearer— they don't do the OIDC dance - Validate the
audienceclaim — without it, a token for App A is accepted by App B - Use
OnTokenValidatedto enrich claims or gate on domain/org membership
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.