Add Auth Fast: JWT & Cookie Patterns for Minimal APIs

Securing Your API Endpoints

Your API works, but anyone can call any endpoint. Without authentication, there's no way to know who's making requests or restrict access to sensitive operations. You need a way to verify user identity and control which endpoints different users can access.

JWT bearer tokens and cookie-based authentication are the two most common patterns for Minimal APIs. JWT works well for mobile apps and SPAs where clients manage tokens explicitly. Cookies work better for traditional web apps where the browser handles authentication automatically. Both integrate seamlessly with ASP.NET Core's authentication middleware.

You'll implement both patterns with working code. By the end, you'll know how to secure endpoints, validate tokens, extract user claims, and apply role-based authorization.

JWT Bearer Authentication

JWT (JSON Web Token) authentication lets clients include a signed token in the Authorization header with each request. The server validates the token's signature and expiration, then extracts claims like user ID and roles. This stateless approach works perfectly for distributed systems where sessions aren't practical.

You configure JWT authentication by specifying the signing key, issuer, and audience. The middleware validates incoming tokens automatically. Invalid or missing tokens return 401 Unauthorized before your endpoint executes.

Here's a complete JWT authentication setup with a login endpoint that generates tokens.

Program.cs
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

var jwtKey = "your-super-secret-key-min-32-chars-long";
var jwtIssuer = "https://yourapi.com";

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = jwtIssuer,
            ValidAudience = jwtIssuer,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(jwtKey))
        };
    });

builder.Services.AddAuthorization();

var app = builder.Build();

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

app.MapPost("/login", (LoginRequest request) =>
{
    if (request.Username != "admin" || request.Password != "password")
        return Results.Unauthorized();

    var claims = new[]
    {
        new Claim(ClaimTypes.Name, request.Username),
        new Claim(ClaimTypes.Role, "Admin"),
        new Claim("userId", "123")
    };

    var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
    var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
    var expiration = DateTime.UtcNow.AddHours(1);

    var token = new JwtSecurityToken(
        issuer: jwtIssuer,
        audience: jwtIssuer,
        claims: claims,
        expires: expiration,
        signingCredentials: creds);

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

    return Results.Ok(new { Token = tokenString, ExpiresAt = expiration });
});

app.MapGet("/secure", () => "You are authenticated!")
    .RequireAuthorization();

app.MapGet("/admin", () => "Admin access granted")
    .RequireAuthorization(policy => policy.RequireRole("Admin"));

app.Run();

record LoginRequest(string Username, string Password);

The /login endpoint validates credentials and generates a JWT with claims. The /secure endpoint requires any authenticated user. The /admin endpoint requires the Admin role. Clients include the token in requests like: Authorization: Bearer {token}.

Accessing User Information from Claims

Once authenticated, you'll often need to know who made the request. Claims contain user identity, roles, and custom data you added during login. You can access the ClaimsPrincipal directly in endpoint parameters, and the framework injects it automatically.

Common claims include ClaimTypes.Name for username, ClaimTypes.Role for roles, and custom claims like userId. You can query claims to make authorization decisions or personalize responses based on the current user.

Here's how to read claims and use them in endpoint logic.

Program.cs
app.MapGet("/user/orders", (ClaimsPrincipal user) =>
{
    var userId = user.FindFirst("userId")?.Value;

    if (userId is null)
        return Results.Unauthorized();

    var orders = new[]
    {
        new { OrderId = 1, UserId = userId, Total = 99.99m },
        new { OrderId = 2, UserId = userId, Total = 149.99m }
    };

    return Results.Ok(orders);
})
.RequireAuthorization();

app.MapGet("/user/profile", (ClaimsPrincipal user) =>
{
    var username = user.Identity?.Name ?? "Unknown";
    var email = user.FindFirst(ClaimTypes.Email)?.Value;
    var isAdmin = user.IsInRole("Admin");

    return Results.Ok(new
    {
        Username = username,
        Email = email,
        IsAdmin = isAdmin
    });
})
.RequireAuthorization();

app.MapDelete("/admin/orders/{id}", (int id, ClaimsPrincipal user) =>
{
    if (!user.IsInRole("Admin"))
        return Results.Forbid();

    return Results.Ok(new { Message = $"Order {id} deleted" });
})
.RequireAuthorization();

The first endpoint extracts userId from claims to filter orders. The second reads multiple claims to build a profile response. The third checks the Admin role before allowing deletion. Use Forbid() when the user is authenticated but lacks permission.

Custom Authorization Policies

Role-based authorization handles many scenarios, but sometimes you need more complex rules. Authorization policies let you define requirements like "must be admin and from the US" or "must own the resource being accessed". You define policies during configuration and reference them by name on endpoints.

Policies compose multiple requirements using policy builders. You can check claims, roles, or implement custom logic. This keeps authorization decisions centralized instead of scattered through endpoint code.

Here's how to create and use custom authorization policies.

Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer();

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", policy =>
        policy.RequireRole("Admin"));

    options.AddPolicy("PremiumUser", policy =>
        policy.RequireClaim("subscription", "premium", "enterprise"));

    options.AddPolicy("RequireEmailVerified", policy =>
        policy.RequireClaim("email_verified", "true"));

    options.AddPolicy("AdminOrPremium", policy =>
        policy.RequireAssertion(context =>
            context.User.IsInRole("Admin") ||
            context.User.HasClaim("subscription", "premium")));
});

var app = builder.Build();

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

app.MapGet("/admin/dashboard", () => "Admin Dashboard")
    .RequireAuthorization("AdminOnly");

app.MapGet("/premium/features", () => "Premium Features")
    .RequireAuthorization("PremiumUser");

app.MapPost("/account/settings", () => "Settings updated")
    .RequireAuthorization("RequireEmailVerified");

app.MapGet("/special", () => "Special Access")
    .RequireAuthorization("AdminOrPremium");

app.Run();

Each policy defines specific requirements. RequireRole checks roles, RequireClaim verifies claim values, and RequireAssertion runs custom logic. Endpoints reference policies by name. Failed authorization returns 403 Forbidden instead of 401 Unauthorized.

Security Best Practices

Use HTTPS everywhere: Never send tokens or credentials over HTTP. Both JWT and cookies can be intercepted on unsecured connections. Enable HSTS headers to force HTTPS and prevent downgrade attacks.

Keep JWT expiration short: Tokens can't be revoked easily, so limit their lifetime to 15-60 minutes. Use refresh tokens stored securely for longer sessions. This minimizes the window where stolen tokens remain valid.

Store signing keys securely: Never hardcode JWT signing keys in source code. Use configuration, environment variables, or key vaults like Azure Key Vault. Rotate keys periodically and support multiple keys during rotation.

Validate everything: Check issuer, audience, expiration, and signature for JWT. For cookies, use SameSite attributes to prevent CSRF and set HttpOnly to block JavaScript access. Validate claims before trusting them in business logic.

Implement proper logout: For cookies, clear the authentication cookie on logout. For JWT, consider maintaining a token blacklist or use short-lived tokens. Don't rely solely on client-side token deletion.

Implement Authentication

Build an API with both JWT and cookie authentication to see how each pattern works. You'll create login endpoints and protected routes.

Steps

  1. dotnet new web -n AuthDemo
  2. cd AuthDemo
  3. dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
  4. Replace Program.cs with the code below
  5. dotnet run
  6. Test login: curl -X POST http://localhost:5000/login -H "Content-Type: application/json" -d '{"username":"admin","password":"password"}'
  7. Test protected: curl http://localhost:5000/secure -H "Authorization: Bearer {token}"
AuthDemo.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
  </ItemGroup>
</Project>
Program.cs
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

var key = "super-secret-key-at-least-32-characters-long";

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
            ValidateIssuer = false,
            ValidateAudience = false
        };
    });
builder.Services.AddAuthorization();

var app = builder.Build();

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

app.MapPost("/login", (LoginRequest req) =>
{
    if (req.Username != "admin" || req.Password != "password")
        return Results.Unauthorized();

    var claims = new[] { new Claim(ClaimTypes.Name, req.Username) };
    var tokenKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
    var creds = new SigningCredentials(tokenKey, SecurityAlgorithms.HmacSha256);

    var token = new JwtSecurityToken(
        claims: claims,
        expires: DateTime.UtcNow.AddHours(1),
        signingCredentials: creds);

    return Results.Ok(new { Token = new JwtSecurityTokenHandler().WriteToken(token) });
});

app.MapGet("/secure", (ClaimsPrincipal user) =>
    $"Hello {user.Identity?.Name}")
    .RequireAuthorization();

app.Run();

record LoginRequest(string Username, string Password);

What you'll see

Login returns a JWT token. Use that token in the Authorization header to access /secure. Without the token or with an invalid token, you'll get 401 Unauthorized. The secure endpoint extracts and displays the username from claims.

Common Questions

JWT or cookies: which should I use?

Use JWT for mobile apps, SPAs, and external API consumers where clients manage tokens. Use cookies for traditional web apps with server-rendered pages. JWT works better for distributed systems and microservices. Cookies simplify CSRF protection and client-side security. Pick based on your client types.

How do I protect against token theft?

Use HTTPS everywhere to prevent interception. Keep JWT expiration short, like 15 minutes, and use refresh tokens for longer sessions. Store tokens in memory or httpOnly cookies, never localStorage. Implement token revocation for critical actions. Monitor for suspicious activity like multiple locations.

Can I use both JWT and cookies in the same API?

Yes, configure multiple authentication schemes and set a default. Mobile clients send JWT in Authorization headers while web clients use cookies automatically. Both work with RequireAuthorization. Just ensure your authentication middleware handles both schemes correctly and clients use the right approach.

How do I handle authorization beyond authentication?

Authentication proves who you are. Authorization decides what you can do. Use claims and roles in your tokens, then check them with RequireAuthorization policies. Create custom policies for complex rules like RequireRole or RequireClaim. Keep authorization logic in policies, not scattered through endpoints.

Back to Articles