• Home
  • /
  • Articles
  • /
  • Software Development
  • /
  • JWT vs. API Keys for ASP.NET Core Web APIs
HEADLINES

JWT vs. API Keys for ASP.NET Core Web APIs Built for Developers

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.


  • Techm Studios
  • BY TECHM STUDIOS
  • |
  • Sept 12, 2026
  • |
  • UPDATED: Sept 12, 2026
  • |
  • 0 COMMENTS
JWT vs. API Keys for ASP.NET Core Web APIs
© Medium / Images

ASP.NET Core · API Design · Developer Experience
.NET 8 / .NET 9+ JWT Bearer API Key Auth

01Two Different Questions

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:

  • JSON Web Tokens (JWTs) answer "which user is this, and what are they allowed to do?" A JWT is a signed, self-contained credential typically issued after a login step, carrying claims about an individual identity, and expiring after a short window.
  • API keys answer "which application or service is calling?" An API key is a long-lived, opaque secret issued to a client application (not a specific end user), typically used to identify the caller for metering, rate limiting, and coarse-grained access control.

Key idea: the two aren't competitors so much as answers to different questions. Many production APIs use both at once — an API key to identify the calling application, and a JWT to identify the individual user within that application's requests.

02JWT vs. API Keys at a Glance

DimensionJWTAPI Key
IdentifiesAn individual user (or a specific client via client-credentials flow)An application, service, or account — not a person
LifetimeShort (minutes) — paired with a refresh tokenLong-lived, often until manually rotated or revoked
ContentSelf-contained: claims, roles, expiry, signatureOpaque random string; meaning lives server-side
RevocationHard to revoke directly (mitigated by short expiry + refresh tokens)Trivial — delete/disable the key server-side
Typical useEnd-user sign-in to a web/mobile appServer-to-server integrations, third-party developer access
Sent asAuthorization: Bearer <token>Custom header (e.g. X-Api-Key), query string, or Basic auth
GranularityFine-grained, per-user claims and rolesCoarse-grained, per-application scopes/rate limits

03When JWTs Are the Right Fit

Reach for JWT bearer authentication when:

  • Your API sits behind a UI where individual users log in (a web app, a mobile app, a SPA).
  • You need fine-grained, per-user authorization — roles, permissions, resource ownership checks (e.g. "can this user edit this order?").
  • You want statelessness: any instance of your API can validate a token by signature alone, with no database round-trip.
  • You're integrating with an existing identity provider (Entra ID, Auth0, Okta, or your own OIDC server) that already issues JWTs.

04When API Keys Are the Right Fit

Reach for API key authentication when:

  • You're publishing a public or partner-facing API for other developers to integrate against, and there's no individual end-user login step involved.
  • You need to meter usage, enforce per-client rate limits, or bill by API key.
  • The caller is a script, a backend service, a CI pipeline, or an IoT device with no interactive login flow.
  • You want instant, simple revocation — disable one key without affecting anyone else.

Caution: an API key is a shared secret, not proof of identity. It says "this is a request from Acme Corp's integration," not "this is Jane from Acme Corp." Don't use a bare API key as a substitute for user authentication in a multi-user application.

05Why This Choice Shapes the UI Built on Top

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:

  • Predictable auth failures make for graceful UI. A UI needs to distinguish "your session expired, please log in again" (JWT expired) from "this application's access has been revoked" (API key disabled) — and show the right message to the right audience. Returning consistent, standard status codes (401 vs. 403) is what makes that possible.
  • Token refresh must be invisible to the user. If a front-end has to silently refresh an access token behind the scenes, every component that calls your API needs to share one refresh mechanism, or users see random logouts mid-session. Actually exercising your endpoints from a real client — not just Swagger — is the only way to catch that class of bug before your consumers do.
  • Rate limits belong in the response, not just the docs. Exposing 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.
  • Consistent claims/scopes make components reusable. If every endpoint encodes permissions the same way, a single "can render this button" check in the UI can be shared across components instead of re-implemented per screen.

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.

06Implementing JWT Authentication

bash
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);
    // ...
}

07Implementing API Key Authentication

ASP.NET Core has no built-in API key handler, so it's implemented as a small custom AuthenticationHandler:

C# — Auth/ApiKeyAuthenticationHandler.cs
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);
}
Store hashed keys, not raw ones. Just like passwords or refresh tokens, hash API keys (SHA-256 is sufficient) before storing them, and compare hashes on lookup.

08Combining Both on the Same API

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.

09Consuming the API Reliably From a UI

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 wrapper
async 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.

10Best Practices for Both

  • Never put a JWT or API key in a URL query string — both end up in server logs, browser history, and proxy caches.
  • Always use HTTPS; neither scheme provides any confidentiality on its own.
  • Return standard status codes (401 for missing/invalid credentials, 403 for valid credentials without sufficient permission) so client code can branch reliably.
  • Version and rotate secrets — support two active API keys per client during rotation so nothing breaks mid-rollover.
  • Rate-limit and log per API key, and per user for JWTs, so abuse is traceable to a specific caller.
  • Document the auth model clearly in your OpenAPI/Swagger definition (securitySchemes) so consumers' tooling can generate correct client code.
  • Test the unhappy paths: expired tokens, revoked keys, missing headers, malformed tokens — these are exactly the states a real UI will eventually hit.

Sources

Written for ASP.NET Core (.NET 8 / .NET 9+). Code samples follow current Microsoft Learn conventions and common community patterns for authentication handlers, and are intended as a practical reference, not a verbatim reproduction of any single source.

Also Read: JWT Auth in ASP.NET Core using SQL Server

Also Read: C# Database Connection with Entity Framework Core

SPONSORED ADS

COMMENTS
LEAVE A REPLY
SPONSORED ADS
POPULAR
AI-Generated Fashion Design

AI-generated fashion design is revolutionizing the industry, pushing the boundaries of creativity and innovation in style.


SPONSORED JOBS

TECHM JOBS
POPULAR
Journalist Jobs, South Africa

Browse most trending Journalist jobs in South Africa. Apply today

Photographer Jobs, South Africa

Browse most trending Photographer jobs in South Africa. Apply today

Graphical Designer Jobs, United Kingdom

Browse most trending Fashion jobs in United Kingdom. Apply today


TECHM PRODUCTS


TOP POSTS
STAY IN TOUCH
SPONSORED ADS
Subscribe to Updates

Get the latest creative news from FooBar about art, design and business.

Subscribe