Friday, December 12, 2025

thumbnail

How to Implement OAuth 2.0 and JWT Authentication in .NET Core

 Great below is a practical, end-to-end guide for implementing OAuth 2.0 (Authorization Code + PKCE for clients) together with JWT access tokens in .NET Core (modern minimal hosting model). It shows both issuing JWTs (simple token endpoint for an internal auth service) and validating them in APIs, plus refresh tokens, claims, scopes, and best practices.

I’ll show:

Required NuGet packages

appsettings.json sample

Token issuing example (Authorization server / Token endpoint)

API validation with JwtBearer

Example of an Authorization Code flow note (using external IdP / IdentityServer)

Refresh token handling

Claims, roles, and policy-based auth

Security and deployment best practices

1. NuGet packages

For a basic internal token server and API:

Microsoft.AspNetCore.Authentication.JwtBearer

System.IdentityModel.Tokens.Jwt

Microsoft.AspNetCore.Identity.EntityFrameworkCore (optional, for Identity)

If you use a production OAuth server solution:

Duende.IdentityServer (commercial license beyond dev) or IdentityServer4 (legacy)

2. appsettings.json (configuration)

{

"Jwt": {

"Issuer": "https://auth.example.com",

"Audience": "api://default",

"Key": "A_VERY_LONG_RANDOM_SYMMETRIC_KEY_AT_LEAST_32_BYTES",

"TokenLifetimeMinutes": 15,

"RefreshTokenLifetimeDays": 30

},

"Logging": { "LogLevel": { "Default": "Information" } }

}

In production prefer asymmetric keys (RS256) stored in a secure store (Key Vault/HSM). Symmetric keys are OK for simple setups but less secure.

3. Minimal Program.cs Token issuer + API validation

Below is a compact example that:

Implements a token endpoint /token (Resource owner password-like for demo; do not use ROPC in production except trusted internal clients)

Issues JWT access tokens and refresh tokens

Configures the API middleware to validate JWTs

using Microsoft.AspNetCore.Authentication.JwtBearer;

using Microsoft.IdentityModel.Tokens;

using System.Security.Claims;

using System.Text;

using System.IdentityModel.Tokens.Jwt;

var builder = WebApplication.CreateBuilder(args);

var config = builder.Configuration;

// Load JWT settings

var jwtSection = config.GetSection("Jwt");

var issuer = jwtSection["Issuer"];

var audience = jwtSection["Audience"];

var key = jwtSection["Key"];

var tokenLifetimeMinutes = int.Parse(jwtSection["TokenLifetimeMinutes"] ?? "15");

var refreshTokenLifetimeDays = int.Parse(jwtSection["RefreshTokenLifetimeDays"] ?? "30");

// Setup Authentication - JWT Bearer

var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));

builder.Services.AddAuthentication(options =>

{

options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;

options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;

})

.AddJwtBearer(options =>

{

options.TokenValidationParameters = new TokenValidationParameters

{

ValidateIssuer = true,

ValidIssuer = issuer,

ValidateAudience = true,

ValidAudience = audience,

ValidateIssuerSigningKey = true,

IssuerSigningKey = signingKey,

ValidateLifetime = true,

ClockSkew = TimeSpan.FromSeconds(60)

};

});

builder.Services.AddAuthorization(options =>

{

options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin"));

});

// Simple in-memory store for refresh tokens (demo only)

var refreshTokensStore = new Dictionary<string, (string UserId, DateTime Expiry)>();

var app = builder.Build();

app.UseAuthentication();

app.UseAuthorization();

// Demo token endpoint (NOT for public ROPC use)

// Accepts JSON: { "username": "...", "password": "..." }

app.MapPost("/token", async (HttpContext http) =>

{

var body = await System.Text.Json.JsonSerializer.DeserializeAsync<LoginRequest>(http.Request.Body);

if (body is null) return Results.BadRequest();

// Authenticate user (replace with real check e.g. Identity, DB)

if (!ValidateUser(body.Username, body.Password, out var userId, out var roles))

return Results.Unauthorized();

var accessToken = CreateJwtToken(userId, roles, issuer, audience, signingKey, tokenLifetimeMinutes);

var refreshToken = Guid.NewGuid().ToString("N");

refreshTokensStore[refreshToken] = (userId, DateTime.UtcNow.AddDays(refreshTokenLifetimeDays));

return Results.Json(new

{

access_token = accessToken,

token_type = "Bearer",

expires_in = tokenLifetimeMinutes * 60,

refresh_token = refreshToken

});

});

// Refresh endpoint

app.MapPost("/token/refresh", async (HttpContext http) =>

{

var refreshReq = await System.Text.Json.JsonSerializer.DeserializeAsync<RefreshRequest>(http.Request.Body);

if (refreshReq is null) return Results.BadRequest();

if (!refreshTokensStore.TryGetValue(refreshReq.RefreshToken, out var entry)) return Results.Unauthorized();

if (entry.Expiry < DateTime.UtcNow)

{

refreshTokensStore.Remove(refreshReq.RefreshToken);

return Results.Unauthorized();

}

// Optionally rotate refresh tokens: delete used token and generate new one

refreshTokensStore.Remove(refreshReq.RefreshToken);

var newRefresh = Guid.NewGuid().ToString("N");

refreshTokensStore[newRefresh] = (entry.UserId, DateTime.UtcNow.AddDays(refreshTokenLifetimeDays));

// Recreate claims/roles for user (fetch from DB in real world)

var roles = new[] { "User" }; // example

var accessToken = CreateJwtToken(entry.UserId, roles, issuer, audience, signingKey, tokenLifetimeMinutes);

return Results.Json(new

{

access_token = accessToken,

token_type = "Bearer",

expires_in = tokenLifetimeMinutes * 60,

refresh_token = newRefresh

});

});

// Protected endpoint

app.MapGet("/api/me", (ClaimsPrincipal user) =>

{

var sub = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;

var name = user.Identity?.Name;

return Results.Json(new { user = name, sub });

}).RequireAuthorization();

// Admin-only endpoint

app.MapGet("/api/admin", () => "Secret admin data").RequireAuthorization("AdminOnly");

app.Run();

// --- Helper types & methods ---

record LoginRequest(string Username, string Password);

record RefreshRequest(string RefreshToken);

bool ValidateUser(string username, string password, out string userId, out string[] roles)

{

// Demo: accept a fixed user.

if (username == "alice" && password == "password123")

{

userId = "alice-id-1";

roles = new[] { "User", "Admin" }; // assign roles as needed

return true;

}

userId = null!;

roles = Array.Empty<string>();

return false;

}

string CreateJwtToken(string userId, string[] roles, string issuer, string audience, SymmetricSecurityKey signingKey, int lifetimeMinutes)

{

var now = DateTime.UtcNow;

var claims = new List<Claim>

{

new Claim(JwtRegisteredClaimNames.Sub, userId),

new Claim(ClaimTypes.NameIdentifier, userId),

new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())

};

claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));

var creds = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);

var token = new JwtSecurityToken(

issuer: issuer,

audience: audience,

claims: claims,

notBefore: now,

expires: now.AddMinutes(lifetimeMinutes),

signingCredentials: creds

);

return new JwtSecurityTokenHandler().WriteToken(token);

}

This example demonstrates the mechanics. Replace in-memory refresh token store & user validation with secure DB-backed storage and real user management (e.g., ASP.NET Core Identity).

4. Authorization Code flow + PKCE (recommended for native & SPA clients)

For most public clients (mobile/SPA), use OAuth 2.0 Authorization Code with PKCE. That means:

Client redirects user to the Authorization Server /authorize endpoint (include code_challenge / code_challenge_method=S256).

User authenticates & consents.

Authorization Server redirects back with code.

Client POSTs to /token with grant_type=authorization_code, code_verifier receives access_token and refresh_token.

You can implement this flow by using a proven authorization server:

Use Duende IdentityServer (recommended for full feature set).

Or use a managed provider: Auth0, Okta, Google/Apple/GitHub, or cloud providers.

Do not implement your own full OAuth server unless you need custom flows use established libraries to avoid security vulnerabilities.

5. Protecting APIs in .NET Core

Use AddAuthentication().AddJwtBearer() as shown. Important options:

.AddJwtBearer(options =>

{

options.Authority = "https://auth.example.com"; // if using external IdP

options.Audience = "api://default";

options.RequireHttpsMetadata = true;

// Optional events for custom validation

options.Events = new JwtBearerEvents

{

OnTokenValidated = ctx => { /* custom claim mapping */ return Task.CompletedTask; },

OnAuthenticationFailed = ctx => { /* logging */ return Task.CompletedTask; }

};

});

If using an external Authorization Server (OpenID Connect / OAuth):

Set Authority to the IdP base URL.

Set Audience or ValidAudience correctly.

The middleware will fetch the IdP’s JWKS and validate RS256 tokens automatically.

6. Claims, Scopes, and Policies

Scopes represent API permissions (read:orders, write:orders). Include them in the token (scope claim or custom).

Claims represent user attributes (sub, email, roles).

Policy-based authorization:

builder.Services.AddAuthorization(options =>

{

options.AddPolicy("CanReadOrders", policy =>

policy.RequireClaim("scope", "orders.read"));

});

Use [Authorize(Policy = "CanReadOrders")] on endpoints.

7. Refresh tokens and rotation

Best practices:

Store refresh tokens server-side with metadata (userId, clientId, issuedAt, expiry, revoked flag).

Use refresh token rotation: when a refresh token is used, issue a new refresh token and revoke the old. This prevents replay attacks.

Detect reuse of rotated tokens and revoke all tokens for that user if reuse detected.

Make refresh tokens long-lived but revokable.

For SPAs, avoid long-lived refresh tokens in browser storage; use rotating refresh tokens with secure HTTP-only cookies or use Authorization Code + PKCE + short-lived access tokens and refresh via backend.

8. Use Asymmetric Signing (recommended)

Production: prefer RS256 / ES256 over HS256.

Generate an RSA keypair.

Sign tokens with the private key.

Publish the public keys via JWKS on a .well-known/jwks.json endpoint.

Configure JwtBearer to use Authority so it can fetch JWKS.

Example signing with RSA:

var rsa = RSA.Create();

rsa.ImportFromPem(File.ReadAllText("private.pem"));

var key = new RsaSecurityKey(rsa);

var creds = new SigningCredentials(key, SecurityAlgorithms.RsaSha256);

9. Integrating with ASP.NET Core Identity

If you need full user management (registration, passwords, confirmation), wire up ASP.NET Core Identity and issue tokens after successful sign-in:

Add Identity EF stores.

After sign-in, create claims principal and issue JWT with user claims and roles fetched from Identity.

Use SignInManager / UserManager to validate credentials.

10. Example: Protecting endpoints with roles & policies

[Authorize(Roles = "Admin")]

[HttpGet("admin")]

public IActionResult AdminOnly() => Ok("Admin data");

[Authorize(Policy = "CanReadOrders")]

[HttpGet("orders")]

public IActionResult Orders() => Ok(new[]{ /* ... */ });

11. Logging, metrics & token introspection

Log token validation failures and auth events (don’t log secrets).

Use token introspection endpoint if using opaque tokens: the resource server calls the Authorization Server to validate tokens.

If using JWTs, introspection is not necessary unless you need revocation checks: maintain a revocation DB or use short-lived tokens.

12. Security checklist & best practices

Use HTTPS everywhere (RequireHttpsMetadata = true).

Use Authorization Code + PKCE for public clients (SPA/mobile).

Use confidential clients (server) to store client secrets safely.

Prefer RS256 (asymmetric) signing.

Keep access token lifetime short (minutes).

Use refresh tokens and rotation; store refresh tokens securely server-side.

Validate audience, issuer, signing key, and lifetime.

Use standard claims and avoid putting sensitive data in tokens.

Store secrets in secure stores (Azure Key Vault, AWS KMS).

Use proven libraries/IdP do not roll your own OAuth server unless necessary.

Monitor auth logs and implement anomaly detection.

13. Common pitfalls

Mistaking audience vs client_id (audience is the API identifier).

Long-lived access tokens increases exposure.

Storing secrets in source code or client-side storage.

Not rotating or revoking refresh tokens.

Relying on symmetric signing for a multi-service environment without proper secret rotation.

14. When to use a full OAuth server (IdentityServer / Duende)

If you need:

Multiple clients, consent screens, scopes, federation, token introspection, rotation, logout, logout tokens

Use a hardened and maintained OAuth server library (Duende IdentityServer or a managed provider).

Managed providers (Auth0, Okta, AWS Cognito) reduce operational burden.

15. Further reading & resources

OAuth 2.0 RFC 6749 (spec)

OAuth 2.1 drafts (authorization code + PKCE guidance)

OpenID Connect (for identity on top of OAuth)

Microsoft docs: JwtBearer and OpenIdConnect middleware

Duende IdentityServer docs (if self-hosting an authorization server)

Learn Dot Net Course in Hyderabad

Read More

Best Practices for Securing Full Stack .NET Applications

Security in Full Stack .NET

Performance Testing and Profiling in .NET Applications

How to Use Azure DevOps for Automated Testing in Full Stack .NET

Visit Our Quality Thought Institute in Hyderabad

Get Directions 

Subscribe by Email

Follow Updates Articles from This Blog via Email

No Comments

About

Search This Blog

Powered by Blogger.

Blog Archive