Skip to content

Adopt JWT Authentication via Client Credentials Flow for protected API routes #105

Open
@nanotaboada

Description

@nanotaboada

Description

We need to secure our 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 adopt 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 standard in public APIs (e.g., FedEx, UPS, Stripe) for securing non-user access.

sequenceDiagram
    participant Client as Client (Machine-to-Machine app)
    participant Server as Server (ASP.NET Core Web API) 

    Note over Client,Server: Step 1 - Obtain Access Token

    Client->>Server: POST /auth/token (client_id, client_secret)
    Server-->>Client: 200 (OK) { access_token, expires_in, token_type }

    Note over Client,Server: Step 2 - Use Token to Access Protected Resources

    Client->>Server: POST /{resource}/{id} (Authorization: Bearer {access_token})
    Server-->>Client: 201 (Created)

    Client->>Server: PUT /{resource}/{id} (Authorization: Bearer {access_token})
    Server-->>Client: 204 (No Content)

    Client->>Server: DELETE /{resource}/{id} (Authorization: Bearer {access_token})
    Server-->>Client: 204 (No Content)
Loading

Proposed Solution

  • Expose a Token Endpoint
    Create a POST /auth/token endpoint that accepts client_id and client_secret via form data.

  • Client Authentication
    Credentials will be validated against values defined in appsettings.json (or environment variables in production).
    If valid, the server will issue a JWT signed using HS256.

  • JWT Token Configuration
    Tokens will include standard claims and be signed using a 256-bit key stored in configuration.
    For production, this key must be injected securely via environment variables.

  • Protect API Routes
    Apply [Authorize] to POST, PUT, and DELETE endpoints.
    The app will use ASP.NET Core’s JwtBearer authentication middleware to validate incoming tokens.

Suggested Approach

1. Store Client Credentials in appsettings.json

"Jwt": {
  "Key": "1LnBfWcu7gTDmqT41QCW4ANu1xsHMcseingKWruVveM=",
  "Issuer": "dotnet-samples-aspnetcore-webapi-issuer",
  "Audience": "dotnet-samples-aspnetcore-webapi-audience",
  "ExpiresInSeconds": "3600"
},
"ClientCredentials": {
  "ClientId": "foobarbaz",
  "ClientSecret": "#!_7h3qu1ck8r0wnf0xjump50v3r7h3l42yd09=@42"
}

2. Create a TokenRequestModel and TokenResponseModel

public class TokenRequestModel
{
    public string GrantType { get; set; } = default!;
    public string ClientId { get; set; } = default!;
    public string ClientSecret { get; set; } = default!;
}
using System.Text.Json.Serialization;

public class TokenResponseModel
{
    [JsonPropertyName("access_token")]
    public string AccessToken { get; set; } = null!;

    [JsonPropertyName("token_type")]
    public string TokenType { get; set; } = "Bearer";

    [JsonPropertyName("expires_in")]
    public int ExpiresIn { get; set; }
}

3. Create ITokenService interface

public interface ITokenService
{
    (string AccessToken, int ExpiresIn) GenerateToken(string clientId);
}

4. Implement ITokenService

public class TokenService : ITokenService
{
    private readonly IConfiguration _config;

    public TokenService(IConfiguration config)
    {
        _config = config;
    }

    public (string AccessToken, int ExpiresIn) GenerateToken(string clientId)
    {
        var expiresIn = int.Parse(_config["Jwt:ExpiresInSeconds"] ?? "3600");
        var expires = DateTime.UtcNow.AddSeconds(expiresIn);

        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, clientId),
            new Claim("client_id", clientId),
            new Claim("scope", "api:write")
        };

        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));
        var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer: _config["Jwt:Issuer"],
            audience: _config["Jwt:Audience"],
            claims: claims,
            expires: expires,
            signingCredentials: signingCredentials
        );

        var accessToken = new JwtSecurityTokenHandler().WriteToken(token);

        return (accessToken, expiresIn);
    }
}

5. Register TokenService in Program.cs

builder.Services.AddScoped<ITokenService, TokenService>();

6. Create AuthController with /auth/token endpoint

[HttpPost("token")]
public IActionResult Token([FromBody] TokenRequestModel request)
{
    if (request.GrantType != "client_credentials")
    {
        return BadRequest(new { error = "unsupported_grant_type" });
    }

    var clientId = _config["ClientCredentials:ClientId"];
    var clientSecret = _config["ClientCredentials:ClientSecret"];

    if (request.ClientId != clientId || request.ClientSecret != clientSecret)
    {
        return Unauthorized(new { error = "invalid_client" });
    }

    var (accessToken, expiresIn) = _tokenService.GenerateToken(request.ClientId);

    var response = new TokenResponseModel
    {
        AccessToken = accessToken,
        ExpiresIn = expiresIn
        // TokenType defaults to "Bearer"
    };

    return Ok(response);
}

7.\ Configure Authentication in Program.cs

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
        };
    });

builder.Services.AddAuthorization();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

8. Protect Sensitive API Routes

[ApiController]
[Route("api/data")]
public class DataController : ControllerBase
{
    [HttpGet]
    public IActionResult Get() => Ok("Public data.");

    [Authorize]
    [HttpPost]
    public IActionResult Post() => Ok("Created.");

    [Authorize]
    [HttpPut("{id}")]
    public IActionResult Put(int id) => Ok("Updated.");

    [Authorize]
    [HttpDelete("{id}")]
    public IActionResult Delete(int id) => Ok("Deleted.");
}

9. Requesting a Token

curl -X POST http://localhost:9000/auth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "client_credentials",
    "client_id": "foobarbaz",
    "client_secret": "#!_7h3qu1ck8r0wnf0xjump50v3r7h3l42yd09_@42"
  }'

Acceptance Criteria

  • /auth/token endpoint issues a JWT for valid clients
  • Credentials are stored in appsettings.json and read securely via IConfiguration
  • Token contains correct claims (sub, client_id, scope)
  • JWTs are short-lived (default: 1 hour)
  • Only authenticated requests can POST, PUT, or DELETE
  • GET remains open to anonymous access
  • Unauthorized clients receive appropriate 401 responses
  • Documentation updated to include:
    • How to obtain a token
    • How to call protected routes with Bearer auth

Metadata

Metadata

Assignees

Labels

.NETPull requests that update .NET codeenhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions