Introduction
In modern distributed systems, securing machine-to-machine (M2M) communication is crucial. OAuth 2.0's Client Credentials grant type is the industry-standard protocol for authenticating services without user interaction. This article demonstrates how to implement OAuth 2.0 Client Credentials flow with scope-based authorization in ASP.NET Core 10, providing fine-grained access control for API resources.
What You'll Learn
- Implementing OAuth 2.0 Client Credentials flow
- Generating and validating JWT tokens with symmetric keys
- Implementing scope-based authorization
- Two different authorization approaches (Custom Handler vs. Policy-Based)
- Best practices for production-ready security
Prerequisites
- .NET 10 SDK
- Understanding of JWT tokens
- Basic knowledge of OAuth 2.0
- Familiarity with ASP.NET Core
Understanding OAuth 2.0 Client Credentials Flow
The Client Credentials flow is used when applications request access tokens to access their own resources, not on behalf of a user.
Flow Diagram
┌──────────┐ ┌────────────────────┐
│ Client │ │ Authorization │
│ App 1 │ │ Server │
└──────────┘ └────────────────────┘
│ │
│ 1. POST /oauth/token │
│ (client_id, client_secret) │
│ ───────────────────────────────────────────> │
│ │
│ │ 2. Validate
│ │ Credentials
│ │
│ 3. JWT Token (scope: products.read) │
│ <─────────────────────────────────────────── │
│ │
│ │
│ ┌──────────────────────┴─────┐
│ │ Resource Server │
│ │ (Protected APIs) │
│ └────────────────────────────┘
│ │
│ 4. GET /api/products │
│ Authorization: Bearer {token} │
│ ───────────────────────────────────────────> │
│ │
│ │ 5. Validate Token
│ │ Check Scope
│ │
│ 6. 200 OK (Products List) │
│ <─────────────────────────────────────────── │
│ │
Key Concepts
- Client Credentials: Application identifies itself using
client_id and client_secret - Scopes: Define what resources the client can access
- JWT Token: Contains client identity and authorized scopes
- Scope-Based Authorization: Resources validate required scopes before granting access
Project Architecture
Project Structure
SecurityApp.API/
├── Authorization/
│ └── ScopeAuthorizationHandler.cs # Custom authorization handler (example)
├── Models/
│ ├── OAuthClient.cs # Client configuration with scopes
│ ├── TokenRequest.cs # OAuth token request DTO
│ └── TokenResponse.cs # OAuth token response DTO
├── Options/
│ ├── JwtOptions.cs # JWT configuration
│ └── OAuthOptions.cs # OAuth clients configuration
├── Services/
│ └── TokenService.cs # Token generation & validation
├── Program.cs # Application startup & endpoints
├── appsettings.json # Configuration
└── SecurityApp.API.http # HTTP test file
Technology Stack
- Framework: ASP.NET Core 10 (Minimal APIs)
- Authentication: JWT Bearer Authentication
- Authorization: Policy-Based Authorization
- Token Generation: System.IdentityModel.Tokens.Jwt
- Cryptography: HMAC-SHA256 (Symmetric Key)
Implementation Guide
Step 1: Create Models
OAuthClient.cs
This model represents a registered OAuth client with its credentials and authorized scopes.
namespace SecurityApp.API.Models;
public class OAuthClient
{
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
public List<string> Scopes { get; set; } = [];
}
TokenRequest.cs
OAuth 2.0 token request following RFC 6749 standard (using snake_case for JSON properties).
using System.Text.Json.Serialization;
namespace SecurityApp.API.Models;
public class TokenRequest
{
[JsonPropertyName("grant_type")]
public string GrantType { get; set; } = string.Empty;
[JsonPropertyName("client_id")]
public string ClientId { get; set; } = string.Empty;
[JsonPropertyName("client_secret")]
public string ClientSecret { get; set; } = string.Empty;
}
Note: JsonPropertyName attributes ensure compatibility with OAuth 2.0 RFC 6749 naming conventions.
TokenResponse.cs
Standard OAuth 2.0 token response.
namespace SecurityApp.API.Models;
public class TokenResponse
{
public string AccessToken { get; set; } = string.Empty;
public string TokenType { get; set; } = "Bearer";
public int ExpiresIn { get; set; }
}
Step 2: Configuration Models
JwtOptions.cs
JWT configuration using the Options Pattern.
namespace SecurityApp.API.Options;
public class JwtOptions
{
public string SecretKey { get; set; } = string.Empty;
public string Issuer { get; set; } = string.Empty;
public string Audience { get; set; } = string.Empty;
public int ExpirationMinutes { get; set; }
}
OAuthOptions.cs
OAuth clients configuration.
using SecurityApp.API.Models;
namespace SecurityApp.API.Options;
public class OAuthOptions
{
public List<OAuthClient> Clients { get; set; } = [];
}
Step 3: Application Configuration
appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Jwt": {
"SecretKey": "MyVerySecureSymmetricKey12345678901234567890",
"Issuer": "http://localhost:5292",
"Audience": "http://localhost:5292",
"ExpirationMinutes": 60
},
"OAuth": {
"Clients": [
{
"ClientId": "client_app_1",
"ClientSecret": "secret_12345",
"Scopes": [ "products.read" ]
},
{
"ClientId": "client_app_2",
"ClientSecret": "secret_67890",
"Scopes": [ "orders.read" ]
}
]
}
}
Scope Assignments:
client_app_1: Can only access /api/products (scope: products.read)client_app_2: Can only access /api/orders (scope: orders.read)
Step 4: Token Service Implementation
TokenService.cs
Handles client validation and JWT token generation with scopes.
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using SecurityApp.API.Models;
using SecurityApp.API.Options;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace SecurityApp.API.Services;
public interface ITokenService
{
TokenResponse? GenerateToken(string clientId);
bool ValidateClient(string clientId, string clientSecret);
}
public class TokenService : ITokenService
{
private readonly JwtOptions _jwtOptions;
private readonly OAuthOptions _oauthOptions;
public TokenService(IOptions<JwtOptions> jwtOptions, IOptions<OAuthOptions> oauthOptions)
{
_jwtOptions = jwtOptions.Value;
_oauthOptions = oauthOptions.Value;
}
public bool ValidateClient(string clientId, string clientSecret)
{
var client = _oauthOptions.Clients
.FirstOrDefault(c => c.ClientId == clientId && c.ClientSecret == clientSecret);
return client is not null;
}
public TokenResponse? GenerateToken(string clientId)
{
// Find client configuration
var client = _oauthOptions.Clients.FirstOrDefault(c => c.ClientId == clientId);
if (client is null)
{
return null;
}
// Create signing credentials with symmetric key
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.SecretKey));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
// Build claims list
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, clientId),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim("client_id", clientId)
};
// Add scope claims for authorization
foreach (var scope in client.Scopes)
{
claims.Add(new Claim("scope", scope));
}
// Create JWT token
var token = new JwtSecurityToken(
issuer: _jwtOptions.Issuer,
audience: _jwtOptions.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_jwtOptions.ExpirationMinutes),
signingCredentials: credentials
);
var tokenHandler = new JwtSecurityTokenHandler();
var accessToken = tokenHandler.WriteToken(token);
return new TokenResponse
{
AccessToken = accessToken,
TokenType = "Bearer",
ExpiresIn = _jwtOptions.ExpirationMinutes * 60
};
}
}
Key Points:
- Client Validation: Verifies client credentials before token generation
- Scope Claims: Each client's scopes are added as claims to the JWT
- Symmetric Signature: Uses HMAC-SHA256 for token signing
- Standard Claims: Includes
sub (subject), jti (JWT ID), and custom client_id
Step 5: Application Startup Configuration
Program.cs (Part 1: Service Registration)
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Tokens;
using SecurityApp.API.Models;
using SecurityApp.API.Options;
using SecurityApp.API.Services;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
// Register OpenAPI for API documentation
builder.Services.AddOpenApi();
// Configure options
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Jwt"));
builder.Services.Configure<OAuthOptions>(builder.Configuration.GetSection("OAuth"));
// Register token service
builder.Services.AddScoped<ITokenService, TokenService>();
// Get JWT options for authentication configuration
var jwtOptions = builder.Configuration.GetSection("Jwt").Get<JwtOptions>();
ArgumentNullException.ThrowIfNull(jwtOptions);
// Configure JWT Bearer Authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtOptions.Issuer,
ValidAudience = jwtOptions.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SecretKey)),
ClockSkew = TimeSpan.Zero // Remove default 5-minute tolerance
};
// JWT Bearer Events for debugging and monitoring
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogInformation("JWT Token Received");
return Task.CompletedTask;
},
OnTokenValidated = context =>
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogInformation("JWT Token Validated Successfully");
if (context.Principal?.Identity is not null)
{
foreach (var claim in context.Principal.Claims)
{
logger.LogInformation("Claim: {Type} = {Value}", claim.Type, claim.Value);
}
}
return Task.CompletedTask;
},
OnAuthenticationFailed = context =>
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogError("JWT Authentication Failed: {Message}", context.Exception.Message);
return Task.CompletedTask;
},
OnChallenge = context =>
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogWarning("JWT Challenge: {Error}", context.AuthenticateFailure?.Message ?? "No token");
return Task.CompletedTask;
}
};
});
// Configure Authorization Policies
builder.Services.AddAuthorization(options =>
{
// Approach 2: Simple policy-based authorization using RequireClaim
options.AddPolicy("ProductsRead", policy =>
policy.RequireClaim("scope", "products.read"));
options.AddPolicy("OrdersRead", policy =>
policy.RequireClaim("scope", "orders.read"));
});
var app = builder.Build();
// Configure middleware pipeline
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
Program.cs (Part 2: Endpoint Definitions)
// OAuth 2.0 Token Endpoint
app.MapPost("/oauth/token", (TokenRequest request, ITokenService tokenService) =>
{
// Validate grant type
if (request.GrantType != "client_credentials")
{
return Results.BadRequest(new
{
error = "unsupported_grant_type",
error_description = "Only client_credentials grant type is supported"
});
}
// Validate required parameters
if (string.IsNullOrWhiteSpace(request.ClientId) || string.IsNullOrWhiteSpace(request.ClientSecret))
{
return Results.BadRequest(new
{
error = "invalid_request",
error_description = "ClientId and ClientSecret are required"
});
}
// Validate client credentials
if (!tokenService.ValidateClient(request.ClientId, request.ClientSecret))
{
return Results.Unauthorized();
}
// Generate and return token
var token = tokenService.GenerateToken(request.ClientId);
return Results.Ok(token);
})
.WithName("GetToken")
.WithOpenApi();
// Protected Resource: Products (requires products.read scope)
app.MapGet("/api/products", [Authorize(Policy = "ProductsRead")] () =>
{
var products = new[]
{
new { Id = 1, Name = "Laptop", Price = 1200.00 },
new { Id = 2, Name = "Mouse", Price = 25.50 },
new { Id = 3, Name = "Keyboard", Price = 75.00 }
};
return Results.Ok(products);
})
.WithName("GetProducts")
.WithOpenApi();
// Protected Resource: Orders (requires orders.read scope)
app.MapGet("/api/orders", [Authorize(Policy = "OrdersRead")] (HttpContext context) =>
{
var clientId = context.User.FindFirst("client_id")?.Value;
var orders = new[]
{
new { Id = 1, ClientId = clientId, Product = "Laptop", Quantity = 2, Total = 2400.00 },
new { Id = 2, ClientId = clientId, Product = "Mouse", Quantity = 5, Total = 127.50 }
};
return Results.Ok(new { ClientId = clientId, Orders = orders });
})
.WithName("GetOrders")
.WithOpenApi();
// Diagnostic Endpoint: Token Inspection (for debugging)
app.MapGet("/api/diagnostics/token", (HttpContext context) =>
{
var authHeader = context.Request.Headers.Authorization.ToString();
if (string.IsNullOrEmpty(authHeader))
{
return Results.Ok(new { Message = "No Authorization header found" });
}
var parts = authHeader.Split(' ');
if (parts.Length != 2 || parts[0] != "Bearer")
{
return Results.Ok(new { Message = "Invalid Authorization header format" });
}
try
{
var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(parts[1]);
return Results.Ok(new
{
Message = "Token parsed successfully",
Issuer = jwtToken.Issuer,
Audiences = jwtToken.Audiences,
ValidFrom = jwtToken.ValidFrom,
ValidTo = jwtToken.ValidTo,
IsExpired = jwtToken.ValidTo < DateTime.UtcNow,
Claims = jwtToken.Claims.Select(c => new { c.Type, c.Value })
});
}
catch (Exception ex)
{
return Results.Ok(new { Message = "Failed to parse token", Error = ex.Message });
}
})
.WithName("DiagnoseToken")
.WithOpenApi();
app.Run();
Authorization Approaches
This project demonstrates two different approaches for implementing scope-based authorization. Both are included in the codebase as examples.
Approach 1: Custom Authorization Handler (Example Only)
This approach uses a custom AuthorizationHandler for more control and flexibility.
ScopeAuthorizationHandler.cs
using Microsoft.AspNetCore.Authorization;
namespace SecurityApp.API.Authorization;
// Custom Requirement
public class ScopeRequirement : IAuthorizationRequirement
{
public string Scope { get; }
public ScopeRequirement(string scope)
{
Scope = scope;
}
}
// Custom Handler
public class ScopeAuthorizationHandler : AuthorizationHandler<ScopeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ScopeRequirement requirement)
{
// Extract scope claims from the token
var scopeClaims = context.User.FindAll("scope").Select(c => c.Value);
// Check if required scope exists
if (scopeClaims.Contains(requirement.Scope))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
Registration (Alternative - Commented Out)
// In Program.cs
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ProductsRead", policy =>
policy.Requirements.Add(new ScopeRequirement("products.read")));
options.AddPolicy("OrdersRead", policy =>
policy.Requirements.Add(new ScopeRequirement("orders.read")));
});
builder.Services.AddSingleton<IAuthorizationHandler, ScopeAuthorizationHandler>();
Advantages:
- ✅ Full control over authorization logic
- ✅ Can inject dependencies (database, external services)
- ✅ Suitable for complex authorization rules
- ✅ Easy to unit test
- ✅ Custom logging and error handling
Use Cases:
- Complex business rules
- Database-driven permissions
- Multi-condition authorization
- Audit logging requirements
Approach 2: Policy-Based with RequireClaim (Currently Used)
This approach uses ASP.NET Core's built-in RequireClaim method.
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ProductsRead", policy =>
policy.RequireClaim("scope", "products.read"));
options.AddPolicy("OrdersRead", policy =>
policy.RequireClaim("scope", "orders.read"));
});
Advantages:
- ✅ Simpler and more readable
- ✅ Built-in ASP.NET Core feature
- ✅ Less code to maintain
- ✅ Better performance (no custom handler overhead)
- ✅ Perfect for simple claim validation
Use Cases:
- Simple scope/claim validation
- JWT claim-based authorization
- Quick implementation
- When custom logic is not required
Comparison Table
| FeatureCustom HandlerRequireClaim |
| Code Complexity | High | Low |
| Flexibility | Very High | Medium |
| Performance | Good | Excellent |
| Testability | Easy | Medium |
| DI Support | ✅ Yes | ❌ No |
| Custom Logic | ✅ Yes | ❌ No |
| Learning Curve | High | Low |
| Best For | Complex rules | Simple claims |
Project Decision
Currently Using: Approach 2 (RequireClaim)
Why?
- This project only validates JWT scope claims
- No additional business logic required
- Simpler and more maintainable
- Better performance
When to use Approach 1? Switch to custom handler if you need:
- Database permission checks
- Role + scope combinations
- Custom audit logging
- Complex authorization rules
Testing the Application
Authorization Matrix
| ClientScope/api/products/api/orders |
| client_app_1 | products.read | ✅ 200 OK | ❌ 403 Forbidden |
| client_app_2 | orders.read | ❌ 403 Forbidden | ✅ 200 OK |
| No Token | - | ❌ 401 Unauthorized | ❌ 401 Unauthorized |
| Invalid Token | - | ❌ 401 Unauthorized | ❌ 401 Unauthorized |
Test Scenarios Using .http File
SecurityApp.API.http
@SecurityApp.API_HostAddress = http://localhost:5292
@token_client1 =
@token_client2 =
### Scenario 1: Get Token for Client 1 (products.read scope)
POST {{SecurityApp.API_HostAddress}}/oauth/token
Content-Type: application/json
{
"grant_type": "client_credentials",
"client_id": "client_app_1",
"client_secret": "secret_12345"
}
###
### Scenario 2: Get Token for Client 2 (orders.read scope)
POST {{SecurityApp.API_HostAddress}}/oauth/token
Content-Type: application/json
{
"grant_type": "client_credentials",
"client_id": "client_app_2",
"client_secret": "secret_67890"
}
###
### ✅ SUCCESS: Client 1 can access Products
GET {{SecurityApp.API_HostAddress}}/api/products
Authorization: Bearer {{token_client1}}
###
### ❌ FORBIDDEN: Client 1 CANNOT access Orders
GET {{SecurityApp.API_HostAddress}}/api/orders
Authorization: Bearer {{token_client1}}
###
### ❌ FORBIDDEN: Client 2 CANNOT access Products
GET {{SecurityApp.API_HostAddress}}/api/products
Authorization: Bearer {{token_client2}}
###
### ✅ SUCCESS: Client 2 can access Orders
GET {{SecurityApp.API_HostAddress}}/api/orders
Authorization: Bearer {{token_client2}}
###
### Diagnostic: Inspect Token
GET {{SecurityApp.API_HostAddress}}/api/diagnostics/token
Authorization: Bearer {{token_client1}}
###
Expected JWT Token Structure
Client 1 Token (products.read)
{
"header": {
"alg": "HS256",
"typ": "JWT"
},
"payload": {
"sub": "client_app_1",
"jti": "unique-jwt-id",
"client_id": "client_app_1",
"scope": "products.read",
"iss": "http://localhost:5292",
"aud": "http://localhost:5292",
"exp": 1234567890
},
"signature": "HMAC-SHA256-signature"
}
Client 2 Token (orders.read)
{
"header": {
"alg": "HS256",
"typ": "JWT"
},
"payload": {
"sub": "client_app_2",
"jti": "unique-jwt-id",
"client_id": "client_app_2",
"scope": "orders.read",
"iss": "http://localhost:5292",
"aud": "http://localhost:5292",
"exp": 1234567890
},
"signature": "HMAC-SHA256-signature"
}
Testing with PowerShell
# Get token for Client 1
$body1 = @{
grant_type = "client_credentials"
client_id = "client_app_1"
client_secret = "secret_12345"
} | ConvertTo-Json
$response1 = Invoke-RestMethod -Uri "http://localhost:5292/oauth/token" `
-Method Post `
-ContentType "application/json" `
-Body $body1
$token1 = $response1.accessToken
Write-Host "Client 1 Token: $token1" -ForegroundColor Green
# Test Products endpoint (should succeed)
$headers1 = @{ Authorization = "Bearer $token1" }
try {
$products = Invoke-RestMethod -Uri "http://localhost:5292/api/products" `
-Method Get -Headers $headers1
Write-Host "✅ Products access successful!" -ForegroundColor Green
$products | ConvertTo-Json
} catch {
Write-Host "❌ Access denied" -ForegroundColor Red
}
# Test Orders endpoint (should fail with 403)
try {
$orders = Invoke-RestMethod -Uri "http://localhost:5292/api/orders" `
-Method Get -Headers $headers1
} catch {
Write-Host "❌ Orders access denied (Expected - 403 Forbidden)" -ForegroundColor Yellow
}
Testing with curl
# Get token for Client 1
curl -X POST http://localhost:5292/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "client_credentials",
"client_id": "client_app_1",
"client_secret": "secret_12345"
}'
# Response
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"tokenType": "Bearer",
"expiresIn": 3600
}
# Access Products (Success)
curl -X GET http://localhost:5292/api/products \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
# Response: 200 OK
[
{ "id": 1, "name": "Laptop", "price": 1200.00 },
{ "id": 2, "name": "Mouse", "price": 25.50 },
{ "id": 3, "name": "Keyboard", "price": 75.00 }
]
# Access Orders (Forbidden)
curl -X GET http://localhost:5292/api/orders \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
# Response: 403 Forbidden
Security Considerations
Production Best Practices
1. Secret Management
❌ Never do this in production:
{
"Jwt": {
"SecretKey": "MyVerySecureSymmetricKey12345678901234567890"
}
}
✅ Use secret management services:
// Azure Key Vault
builder.Configuration.AddAzureKeyVault(
new Uri($"https://{keyVaultName}.vault.azure.net/"),
new DefaultAzureCredential());
// AWS Secrets Manager
builder.Configuration.AddSecretsManager();
// Environment Variables
var secretKey = Environment.GetEnvironmentVariable("JWT_SECRET_KEY");
2. HTTPS Enforcement
// Force HTTPS in production
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
app.UseHsts();
}
3. Token Expiration
{
"Jwt": {
"ExpirationMinutes": 60 // Adjust based on security requirements
}
}
Recommendations:
- Short-lived tokens: 15-60 minutes
- Long-lived tokens: Implement refresh tokens
- Critical operations: Require re-authentication
4. Scope Granularity
Good Practice:
{
"Scopes": [
"products.read",
"products.write",
"products.delete",
"orders.read",
"orders.write"
]
}
Poor Practice:
{
"Scopes": [ "admin", "all" ] // Too broad
}
5. Rate Limiting
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("token", options =>
{
options.Window = TimeSpan.FromMinutes(1);
options.PermitLimit = 10;
});
});
app.MapPost("/oauth/token", ...)
.RequireRateLimiting("token");
6. Logging and Monitoring
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
var logger = context.HttpContext.RequestServices
.GetRequiredService<ILogger<Program>>();
// Log security events
logger.LogWarning("Authentication failed: {Error} from IP: {IP}",
context.Exception.Message,
context.Request.HttpContext.Connection.RemoteIpAddress);
// Track failed attempts
// Implement IP blocking if necessary
return Task.CompletedTask;
}
};
7. Token Validation Parameters
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true, // Verify token issuer
ValidateAudience = true, // Verify intended audience
ValidateLifetime = true, // Check expiration
ValidateIssuerSigningKey = true, // Verify signature
ClockSkew = TimeSpan.Zero, // Remove default 5-min tolerance
RequireExpirationTime = true, // Must have exp claim
RequireSignedTokens = true // Must be signed
};
8. Client Secret Storage
Best Practices:
- Hash client secrets (like passwords)
- Use strong secrets (minimum 32 characters)
- Rotate secrets regularly
- Store in secure vaults (Azure Key Vault, AWS Secrets Manager)
// Example: Hash client secrets
public bool ValidateClient(string clientId, string clientSecret)
{
var client = _oauthOptions.Clients.FirstOrDefault(c => c.ClientId == clientId);
if (client is null) return false;
// Use proper password hashing (e.g., BCrypt, Argon2)
return BCrypt.Net.BCrypt.Verify(clientSecret, client.ClientSecretHash);
}
Advanced Scenarios
Multiple Scopes (OR Logic)
// Client can have products.read OR products.write
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ProductsAccess", policy =>
policy.RequireAssertion(context =>
context.User.HasClaim("scope", "products.read") ||
context.User.HasClaim("scope", "products.write")));
});
app.MapGet("/api/products", [Authorize(Policy = "ProductsAccess")] () => { ... });
Multiple Scopes (AND Logic)
// Client must have BOTH scopes
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminProducts", policy =>
{
policy.RequireClaim("scope", "products.read");
policy.RequireClaim("scope", "admin");
});
});
Database-Driven Permissions
public class DatabasePermissionHandler : AuthorizationHandler<PermissionRequirement>
{
private readonly IPermissionService _permissionService;
public DatabasePermissionHandler(IPermissionService permissionService)
{
_permissionService = permissionService;
}
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PermissionRequirement requirement)
{
var clientId = context.User.FindFirst("client_id")?.Value;
var hasPermission = await _permissionService
.CheckPermissionAsync(clientId, requirement.Permission);
if (hasPermission)
{
context.Succeed(requirement);
}
}
}
Troubleshooting
Common Issues
1. 401 Unauthorized
Possible Causes:
- Token missing or malformed
- Token expired
- Invalid signature
- Issuer/Audience mismatch
Solution:
// Enable detailed logging
"Logging": {
"LogLevel": {
"Microsoft.AspNetCore.Authentication": "Debug"
}
}
2. 403 Forbidden
Cause: Token is valid but lacks required scope.
Verify:
GET /api/diagnostics/token
Authorization: Bearer {token}
Check if the scope claim matches the policy requirement.
3. Token Not Validated
Check:
- SecretKey matches between token generation and validation
- Issuer and Audience values are correct
- ClockSkew settings
Performance Optimization
1. Token Caching
// Cache token validation results
builder.Services.AddMemoryCache();
builder.Services.AddDistributedMemoryCache();
options.SaveToken = true; // Cache token in AuthenticationProperties
2. Use Singleton for Handler
// Handlers are stateless - use singleton
builder.Services.AddSingleton<IAuthorizationHandler, ScopeAuthorizationHandler>();
3. Async Where Possible
public async Task<TokenResponse?> GenerateTokenAsync(string clientId)
{
// If you need database/external service calls
var client = await _clientRepository.GetByIdAsync(clientId);
// ...
}