You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
We need to secure API endpoints that perform data mutations (POST, PUT, DELETE) so that only authorized machine-to-machine (M2M) clients — such as CLIs, daemons, or backend services — can access them.
To achieve this, we will implement JWT Bearer authentication using the OAuth 2.0 Client Credentials Flow. This allows external systems to authenticate with a client_id and client_secret, receive a signed JWT, and use it to authorize calls to protected API routes.
This pattern is the industry standard for M2M authentication used by public APIs (FedEx, UPS, Stripe, GitHub, etc.).
Authentication Flow
sequenceDiagram
participant Client as Client (M2M Application)
participant Auth as Auth Endpoint
participant API as Protected API Endpoint
Note over Client,API: Step 1 - Obtain Access Token
Client->>Auth: POST /auth/token<br/>(client_id, client_secret)
Auth->>Auth: Validate credentials
Auth->>Auth: Generate JWT with claims
Auth-->>Client: 200 OK<br/>{access_token, expires_in, token_type}
Note over Client,API: Step 2 - Access Protected Resources
Client->>API: POST /players<br/>Authorization: Bearer {token}
API->>API: Validate JWT signature
API->>API: Check claims & expiration
API-->>Client: 201 Created
Client->>API: PUT /players/7<br/>Authorization: Bearer {token}
API->>API: Validate JWT
API-->>Client: 204 No Content
Client->>API: DELETE /players/7<br/>Authorization: Bearer {token}
API->>API: Validate JWT
API-->>Client: 204 No Content
Loading
Current State
✅ All endpoints are currently public (no authentication required)
❌ No protection for data-modifying operations
❌ No audit trail of who made changes
❌ Unsuitable for production deployment
Goals
Secure write operations: POST, PUT, DELETE require valid JWT
Keep reads public: GET endpoints remain accessible without authentication
{
"Jwt": {
"Key": "", // Will be set via environment variable"Issuer": "dotnet-samples-aspnetcore-webapi",
"Audience": "dotnet-samples-aspnetcore-webapi-api",
"ExpirationMinutes": 60
},
"ClientCredentials": {
"ClientId": "", // Will be set via environment variable"ClientSecret": ""// Will be set via environment variable
}
}
2. Add Production Configuration (appsettings.Production.json)
{
"Jwt": {
"ExpirationMinutes": 15// Shorter expiration for production
}
}
namespaceDotnet.Samples.AspNetCore.WebApi.Services;publicinterfaceITokenService{/// <summary>/// Generates a JWT access token for the specified client./// </summary>/// <param name="clientId">The client identifier</param>/// <returns>A tuple containing the access token and expiration time in seconds</returns>(stringAccessToken,intExpiresIn)GenerateToken(stringclientId);/// <summary>/// Validates client credentials against configured values./// </summary>/// <param name="clientId">The client identifier</param>/// <param name="clientSecret">The client secret</param>/// <returns>True if credentials are valid</returns>boolValidateClientCredentials(stringclientId,stringclientSecret);}
6. Implement TokenService
Services/TokenService.cs:
usingSystem.IdentityModel.Tokens.Jwt;usingSystem.Security.Claims;usingSystem.Text;usingMicrosoft.IdentityModel.Tokens;namespaceDotnet.Samples.AspNetCore.WebApi.Services;publicclassTokenService:ITokenService{privatereadonlyIConfiguration_configuration;privatereadonlyILogger<TokenService>_logger;publicTokenService(IConfigurationconfiguration,ILogger<TokenService>logger){_configuration=configuration;_logger=logger;}publicboolValidateClientCredentials(stringclientId,stringclientSecret){varconfiguredClientId=_configuration["ClientCredentials:ClientId"];varconfiguredClientSecret=_configuration["ClientCredentials:ClientSecret"];if(string.IsNullOrEmpty(configuredClientId)||string.IsNullOrEmpty(configuredClientSecret)){_logger.LogError("Client credentials not configured");returnfalse;}// Use constant-time comparison to prevent timing attacksvarclientIdValid=CryptographicEquals(clientId,configuredClientId);varsecretValid=CryptographicEquals(clientSecret,configuredClientSecret);returnclientIdValid&&secretValid;}public(stringAccessToken,intExpiresIn)GenerateToken(stringclientId){varexpirationMinutes=_configuration.GetValue<int>("Jwt:ExpirationMinutes",60);varexpiresIn=expirationMinutes*60;// Convert to secondsvarexpires=DateTime.UtcNow.AddMinutes(expirationMinutes);varclaims=new[]{newClaim(JwtRegisteredClaimNames.Sub,clientId),newClaim(JwtRegisteredClaimNames.Jti,Guid.NewGuid().ToString()),newClaim(JwtRegisteredClaimNames.Iat,DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),newClaim("client_id",clientId),newClaim("scope","api:write")};varkey=_configuration["Jwt:Key"];if(string.IsNullOrEmpty(key)){thrownewInvalidOperationException("JWT signing key is not configured");}varsigningKey=newSymmetricSecurityKey(Encoding.UTF8.GetBytes(key));varsigningCredentials=newSigningCredentials(signingKey,SecurityAlgorithms.HmacSha256);vartoken=newJwtSecurityToken(issuer:_configuration["Jwt:Issuer"],audience:_configuration["Jwt:Audience"],claims:claims,expires:expires,signingCredentials:signingCredentials);varaccessToken=newJwtSecurityTokenHandler().WriteToken(token);_logger.LogInformation("Generated JWT for client {ClientId}, expires at {ExpiresAt}",clientId,expires);return(accessToken,expiresIn);}// Constant-time string comparison to prevent timing attacksprivatestaticboolCryptographicEquals(stringa,stringb){if(a==null||b==null)returnfalse;if(a.Length!=b.Length)returnfalse;varresult=0;for(vari=0;i<a.Length;i++){result|=a[i]^b[i];}returnresult==0;}}
Phase 3: Authentication Controller
7. Create AuthController
Controllers/AuthController.cs:
usingDotnet.Samples.AspNetCore.WebApi.Models.Auth;usingDotnet.Samples.AspNetCore.WebApi.Services;usingMicrosoft.AspNetCore.Mvc;namespaceDotnet.Samples.AspNetCore.WebApi.Controllers;[ApiController][Route("auth")][Produces("application/json")]publicclassAuthController:ControllerBase{privatereadonlyITokenService_tokenService;privatereadonlyILogger<AuthController>_logger;publicAuthController(ITokenServicetokenService,ILogger<AuthController>logger){_tokenService=tokenService;_logger=logger;}/// <summary>/// OAuth 2.0 Token Endpoint (Client Credentials Flow)/// </summary>/// <param name="request">Token request containing client credentials</param>/// <returns>JWT access token if credentials are valid</returns>[HttpPost("token")][ProducesResponseType(typeof(TokenResponseModel),StatusCodes.Status200OK)][ProducesResponseType(typeof(ErrorResponseModel),StatusCodes.Status400BadRequest)][ProducesResponseType(typeof(ErrorResponseModel),StatusCodes.Status401Unauthorized)]publicIActionResultToken([FromBody]TokenRequestModelrequest){// Validate grant typeif(request.GrantType!="client_credentials"){_logger.LogWarning("Unsupported grant type: {GrantType}",request.GrantType);returnBadRequest(newErrorResponseModel{Error="unsupported_grant_type",ErrorDescription="Only 'client_credentials' grant type is supported"});}// Validate client credentialsif(!_tokenService.ValidateClientCredentials(request.ClientId,request.ClientSecret)){_logger.LogWarning("Invalid client credentials for client_id: {ClientId}",request.ClientId);returnUnauthorized(newErrorResponseModel{Error="invalid_client",ErrorDescription="Client authentication failed"});}// Generate tokentry{var(accessToken,expiresIn)=_tokenService.GenerateToken(request.ClientId);returnOk(newTokenResponseModel{AccessToken=accessToken,ExpiresIn=expiresIn});}catch(Exceptionex){_logger.LogError(ex,"Error generating token for client: {ClientId}",request.ClientId);returnStatusCode(500,newErrorResponseModel{Error="server_error",ErrorDescription="An error occurred while generating the token"});}}}
9. Register Services in ServiceCollectionExtensions.cs
publicstaticIServiceCollectionAddJwtAuthentication(thisIServiceCollectionservices,IConfigurationconfiguration){services.AddScoped<ITokenService,TokenService>();services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>{options.TokenValidationParameters=newTokenValidationParameters{ValidateIssuer=true,ValidateAudience=true,ValidateLifetime=true,ValidateIssuerSigningKey=true,ClockSkew=TimeSpan.Zero,// No clock skew toleranceValidIssuer=configuration["Jwt:Issuer"],ValidAudience=configuration["Jwt:Audience"],IssuerSigningKey=newSymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Key"]!))};options.Events=newJwtBearerEvents{OnAuthenticationFailed= context =>{if(context.ExceptionisSecurityTokenExpiredException){context.Response.Headers.Append("Token-Expired","true");}returnTask.CompletedTask;}};});services.AddAuthorization();returnservices;}
10. Update Program.cs
// Add authentication & authorizationbuilder.Services.AddJwtAuthentication(builder.Configuration);varapp=builder.Build();// Add middleware (ORDER MATTERS!)app.UseAuthentication();// Must come before UseAuthorizationapp.UseAuthorization();
Phase 5: Protect API Endpoints
11. Update PlayerController
usingMicrosoft.AspNetCore.Authorization;[ApiController][Route("players")]publicclassPlayerController:ControllerBase{// Public endpoint - no authentication required[HttpGet][AllowAnonymous]publicasyncTask<IActionResult>GetAll(){/* ... */}// Public endpoint - no authentication required[HttpGet("{id}")][AllowAnonymous]publicasyncTask<IActionResult>GetById(Guidid){/* ... */}// Protected endpoint - requires JWT[HttpPost][Authorize]publicasyncTask<IActionResult>Create([FromBody]PlayerRequestModelmodel){/* ... */}// Protected endpoint - requires JWT[HttpPut("squad/{squadNumber}")][Authorize]publicasyncTask<IActionResult>Update(intsquadNumber,[FromBody]PlayerRequestModelmodel){/* ... */}// Protected endpoint - requires JWT[HttpDelete("squad/{squadNumber}")][Authorize]publicasyncTask<IActionResult>Delete(intsquadNumber){/* ... */}}
Description
We need to secure API endpoints that perform data mutations (
POST,PUT,DELETE) so that only authorized machine-to-machine (M2M) clients — such as CLIs, daemons, or backend services — can access them.To achieve this, we will implement JWT Bearer authentication using the OAuth 2.0 Client Credentials Flow. This allows external systems to authenticate with a
client_idandclient_secret, receive a signed JWT, and use it to authorize calls to protected API routes.This pattern is the industry standard for M2M authentication used by public APIs (FedEx, UPS, Stripe, GitHub, etc.).
Authentication Flow
sequenceDiagram participant Client as Client (M2M Application) participant Auth as Auth Endpoint participant API as Protected API Endpoint Note over Client,API: Step 1 - Obtain Access Token Client->>Auth: POST /auth/token<br/>(client_id, client_secret) Auth->>Auth: Validate credentials Auth->>Auth: Generate JWT with claims Auth-->>Client: 200 OK<br/>{access_token, expires_in, token_type} Note over Client,API: Step 2 - Access Protected Resources Client->>API: POST /players<br/>Authorization: Bearer {token} API->>API: Validate JWT signature API->>API: Check claims & expiration API-->>Client: 201 Created Client->>API: PUT /players/7<br/>Authorization: Bearer {token} API->>API: Validate JWT API-->>Client: 204 No Content Client->>API: DELETE /players/7<br/>Authorization: Bearer {token} API->>API: Validate JWT API-->>Client: 204 No ContentCurrent State
Goals
POST,PUT,DELETErequire valid JWTGETendpoints remain accessible without authenticationImplementation Strategy
Phase 1: Configuration & Models
1. Add JWT Configuration to
appsettings.json{ "Jwt": { "Key": "", // Will be set via environment variable "Issuer": "dotnet-samples-aspnetcore-webapi", "Audience": "dotnet-samples-aspnetcore-webapi-api", "ExpirationMinutes": 60 }, "ClientCredentials": { "ClientId": "", // Will be set via environment variable "ClientSecret": "" // Will be set via environment variable } }2. Add Production Configuration (
appsettings.Production.json){ "Jwt": { "ExpirationMinutes": 15 // Shorter expiration for production } }3. Create
.env.examplefor Local DevelopmentGenerate a secure key:
# Generate 256-bit key (32 bytes, base64 encoded) openssl rand -base64 324. Create Request/Response Models
Models/Auth/TokenRequestModel.cs:Models/Auth/TokenResponseModel.cs:Models/Auth/ErrorResponseModel.cs:Phase 2: Token Service
5. Create
ITokenServiceInterfaceServices/ITokenService.cs:6. Implement
TokenServiceServices/TokenService.cs:Phase 3: Authentication Controller
7. Create
AuthControllerControllers/AuthController.cs:Phase 4: Configure Authentication & Authorization
8. Add NuGet Package
9. Register Services in
ServiceCollectionExtensions.cs10. Update
Program.csPhase 5: Protect API Endpoints
11. Update
PlayerControllerPhase 6: Testing
12. Manual Testing with cURL
Step 1: Request a token
Response:
{ "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 3600, "scope": "api:write" }Step 2: Use token to create a player
Step 3: Test without token (should fail)
Expected response: 401 Unauthorized
13. Unit Tests
test/Unit/AuthControllerTests.cs:Security Best Practices
✅ Implemented
JWT_KEYand client secrets periodically/auth/tokenendpoint to prevent brute force🔐 Secret Storage by Environment
.envfile (git-ignored).envfileDocumentation Updates
README.md
Add new section:
Use Token
Protected Endpoints
POST /players- Create playerPUT /players/squad/{squadNumber}- Update playerDELETE /players/squad/{squadNumber}- Delete playerPublic Endpoints
GET /players- List all playersGET /players/{id}- Get player by IDGET /players/squad/{squadNumber}- Get player by squad numberAcceptance Criteria
Functionality
/auth/tokenendpoint issues JWT for valid client credentialssub,jti,iat,client_id,scope)401 Unauthorizedwith proper error response400 Bad RequestSecurity
Authorization
POST /playersrequires valid JWTPUT /players/squad/{squadNumber}requires valid JWTDELETE /players/squad/{squadNumber}requires valid JWTGET /playersremains publicly accessibleGET /players/{id}remains publicly accessible401 Unauthorized401 UnauthorizedTesting
TokenService.GenerateToken()TokenService.ValidateClientCredentials()AuthController.Token()(valid/invalid credentials)Documentation
.env.examplecontains all required auth variablesDocker
.env)Migration Path
References