Two different problems that look similar: proving who a user is versus proving which application is calling. Getting this choice right is what lets other developers build fast, reliable UI on top of your API — and getting it wrong is what makes their interfaces flaky, slow, or insecure.
Authentication is the process of determining who or what is calling your API. But "who" can mean two very different things, and conflating them is one of the most common design mistakes in Web APIs meant to be consumed by other developers:
| Dimension | JWT | API Key |
|---|---|---|
| Identifies | An individual user (or a specific client via client-credentials flow) | An application, service, or account — not a person |
| Lifetime | Short (minutes) — paired with a refresh token | Long-lived, often until manually rotated or revoked |
| Content | Self-contained: claims, roles, expiry, signature | Opaque random string; meaning lives server-side |
| Revocation | Hard to revoke directly (mitigated by short expiry + refresh tokens) | Trivial — delete/disable the key server-side |
| Typical use | End-user sign-in to a web/mobile app | Server-to-server integrations, third-party developer access |
| Sent as | Authorization: Bearer <token> | Custom header (e.g. X-Api-Key), query string, or Basic auth |
| Granularity | Fine-grained, per-user claims and roles | Coarse-grained, per-application scopes/rate limits |
Reach for JWT bearer authentication when:
Reach for API key authentication when:
An API's authentication model isn't just a backend concern — it directly determines how reliable, responsive, and maintainable the UI built on top of it can be. Every screen, dashboard, and form your consumers build is only as robust as the endpoints backing it, so the contract you expose deserves the same care as the pixels on screen:
Retry-After and remaining-quota headers on API-key-authenticated endpoints lets
client UIs throttle themselves gracefully instead of hammering a 429 in a retry loop.
In short: building and actually calling your endpoints end-to-end, from a real UI or a representative client, is what surfaces the rough edges — ambiguous error codes, missing headers, inconsistent claim shapes — that no amount of reading the API contract in isolation will reveal.
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
C# — Program.cs
var jwt = builder.Configuration.GetSection("Jwt");
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt["SigningKey"]!));
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwt["Issuer"],
ValidateAudience = true,
ValidAudience = jwt["Audience"],
ValidateIssuerSigningKey = true,
IssuerSigningKey = signingKey,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanManageOrders", p => p.RequireClaim("scope", "orders.write"));
});
C#
[Authorize(Policy = "CanManageOrders")]
[HttpPut("/api/orders/{id}")]
public async Task<IActionResult> UpdateOrder(int id, OrderDto dto)
{
// Fine-grained, per-user authorization check
var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub);
// ...
}
ASP.NET Core has no built-in API key handler, so it's implemented as a small custom
AuthenticationHandler:
public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions { }
public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
public const string SchemeName = "ApiKey";
private const string HeaderName = "X-Api-Key";
private readonly IApiKeyStore _apiKeyStore;
public ApiKeyAuthenticationHandler(
IOptionsMonitor<ApiKeyAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IApiKeyStore apiKeyStore)
: base(options, logger, encoder)
{
_apiKeyStore = apiKeyStore;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(HeaderName, out var providedKey))
return AuthenticateResult.NoResult();
var client = await _apiKeyStore.FindByKeyAsync(providedKey.ToString());
if (client is null || !client.IsActive)
return AuthenticateResult.Fail("Invalid or revoked API key.");
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, client.ClientId),
new Claim("client_name", client.Name)
};
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return AuthenticateResult.Success(ticket);
}
}
C# — Program.cs
builder.Services.AddScoped<IApiKeyStore, SqlApiKeyStore>();
builder.Services
.AddAuthentication()
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
ApiKeyAuthenticationHandler.SchemeName, options => { });
C#
[Authorize(AuthenticationSchemes = ApiKeyAuthenticationHandler.SchemeName)]
[HttpGet("/api/partners/orders")]
public async Task<IActionResult> GetOrdersForPartner()
{
var clientId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var orders = await _orderService.GetOrdersForClientAsync(clientId!);
return Ok(orders);
}
A common real-world need: authenticate the calling application via API key and the specific user via JWT on the same endpoint — for example, a partner integration acting on behalf of one of its own users. ASP.NET Core supports multiple authentication schemes, selected per-endpoint or via a composite handler that inspects the incoming request and delegates to the matching scheme:
C#[Authorize(AuthenticationSchemes =
$"{JwtBearerDefaults.AuthenticationScheme},{ApiKeyAuthenticationHandler.SchemeName}")]
[HttpGet("/api/reports")]
public IActionResult GetReports() => Ok(_reportService.GetAll());
Listing multiple schemes means ASP.NET Core accepts a request authenticated by either scheme; a composite handler goes further and lets you require both at once (API key identifying the app, JWT identifying the user within it) for endpoints that need both layers of context.
Whichever scheme you choose, the endpoints only become "robust" once someone actually builds against them. A few habits make that integration much smoother for whoever's building the UI:
JavaScript — example fetch wrapperasync function callApi(path, { method = "GET", body, apiKey, accessToken } = {}) {
const headers = { "Content-Type": "application/json" };
if (apiKey) headers["X-Api-Key"] = apiKey;
if (accessToken) headers["Authorization"] = `Bearer ${accessToken}`;
const response = await fetch(path, { method, headers, body: body && JSON.stringify(body) });
if (response.status === 401) {
// Distinguish "token expired" from "key revoked" using a response body code,
// not just the status, so the UI can react correctly.
const problem = await response.json().catch(() => null);
throw new ApiAuthError(problem?.detail ?? "Unauthorized", problem?.code);
}
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After");
throw new RateLimitError(retryAfter);
}
return response.json();
}
Exercising this path against real endpoints — not a mocked response — is what surfaces whether your 401s are actually distinguishable, whether your rate-limit headers exist, and whether a token refresh mid-session actually works without dropping the user's in-flight request. That verification loop is what turns an API contract on paper into a UI that holds up in production.
securitySchemes) so consumers' tooling can generate correct client code.
Also Read: JWT Auth in ASP.NET Core using SQL Server
Also Read: C# Database Connection with Entity Framework Core
AI-generated fashion design is revolutionizing the industry, pushing the boundaries of creativity and innovation in style.
Browse most trending Journalist jobs in South Africa. Apply today
Browse most trending Photographer jobs in South Africa. Apply today
Browse most trending Fashion jobs in United Kingdom. Apply today
AI-Generated Fashion Design
JUN 12, 2026
Streetwear Luxury Collaborations
JUN 12, 2026 5,617 VIEWS
Get the latest creative news from FooBar about art, design and business.
Subscribe