Skip to main content
ANVISoftware Solutions
Lesson 16 of 23Advanced18 min

Authentication

By the end of this lesson

Establish who the caller is and validate credentials properly.

Authentication answers one question: who is making this request? Nothing about permissions yet. That comes next, and it depends entirely on getting this step right.

Most APIs answer it with a bearer token. The caller obtains a token from an identity provider — your own login service, or a hosted one — and sends it on every subsequent request in the Authorization header. The API checks the token and, if it holds up, treats the request as coming from the person the token describes.

The token format you will meet most often is a JSON Web Token, usually shortened to JWT. It is three sections joined by dots: a header saying how it was signed, a payload of claims, and a signature. A claim is one statement about the subject, such as their identifier or their email address.

Five properties of a token, each of which has to be checked, and each of which fails differently:

Signature
Proof the token was produced by the party holding the signing key and has not been altered since. Verifying it is what makes any of the other claims worth reading.
Issuer
Who created the token, in the iss claim. You check it against the issuer you configured, so a token minted by a different provider is not accepted merely because it is well formed.
Audience
Who the token was intended for, in the aud claim. This stops a token issued for one service being replayed against another. If you do not check it, any service that trusts the same issuer accepts each other's tokens.
Lifetime
The exp claim, and often nbf for not-before. A short lifetime limits how long a leaked token remains useful, which is the main reason they are short.
Signing key
The key used to verify the signature. For a hosted provider it is fetched from a well-known metadata endpoint and refreshed, so key rotation does not break your service.
Program.cs — JWT bearer validation with every check explicit
C#
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        // The provider's metadata endpoint supplies the signing keys and
        // refreshes them, so rotating a key does not require a deployment.
        options.Authority = builder.Configuration["Auth:Authority"];
        options.Audience = builder.Configuration["Auth:Audience"];

        // HTTPS for metadata retrieval. Leave this on.
        options.RequireHttpsMetadata = true;

        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            ValidateIssuer = true,
            ValidIssuer = builder.Configuration["Auth:Authority"],
            ValidateAudience = true,
            ValidAudience = builder.Configuration["Auth:Audience"],
            ValidateLifetime = true,

            // Default tolerance for clock differences is five minutes.
            // Tighten it when your servers have reliable time.
            ClockSkew = TimeSpan.FromSeconds(30),

            // Keep claim names as the token wrote them, instead of the
            // legacy mapping to long WS-* URIs.
            NameClaimType = "name",
            RoleClaimType = "roles",
        };

        options.MapInboundClaims = false;
    });

builder.Services.AddAuthorization();

WebApplication app = builder.Build();

app.UseHttpsRedirection();
app.UseAuthentication();   // works out who the caller is
app.UseAuthorization();    // decides whether they may proceed
app.MapControllers();
  • Authority is the base address of the identity provider. The handler appends the standard discovery path, downloads the signing keys and caches them, which is what makes key rotation transparent.
  • Every Validate flag is set explicitly here. Several are on by default, but writing them out makes the intent reviewable — a reader can see that nothing was skipped.
  • ClockSkew is the allowance for servers whose clocks disagree. Five minutes is the default, and it means an expired token can still be accepted for up to five minutes. Reduce it when you control the hosts.
  • MapInboundClaims set to false stops the legacy rewriting of short claim names into long URI-style names. Without it, the sub claim arrives as a lengthy identifier and code that looks for "sub" finds nothing.
  • UseAuthentication has to come before UseAuthorization. Authorization asks who the caller is, so the step that answers that question must already have run.

What happens on each request once that is configured:

  1. The scheme is selected

    The authentication middleware picks the configured scheme — bearer, in this case — and hands the request to its handler.

  2. The token is extracted

    The handler reads the Authorization header and expects the form "Bearer" followed by the token. A missing or malformed header means no token, which is not an error at this stage.

  3. The signature is verified

    The handler finds the matching key from the provider's metadata and checks the signature. If it does not match, validation stops here and the claims are never trusted.

  4. Issuer, audience and time are checked

    Each configured check runs. A token that is genuine but expired, or genuine but issued for another service, fails at this point.

  5. A principal is built

    On success the handler creates a ClaimsPrincipal from the claims and assigns it to HttpContext.User. Your code reads the caller's identity from there and nowhere else.

  6. Failure means anonymous, not rejected

    If validation fails, no identity is set and the request continues as anonymous. The 401 or 403 comes from authorization, which is why an endpoint with no [Authorize] attribute stays open even with a broken token.

Reading the authenticated caller in a controller
C#
[ApiController]
[Route("api/employees")]
[Authorize]
public sealed class EmployeesController(IEmployeeService employees) : ControllerBase
{
    [HttpGet("me")]
    public async Task<ActionResult<EmployeeResponse>> GetMyRecord(
        CancellationToken cancellationToken)
    {
        // The subject claim identifies the caller. With MapInboundClaims off,
        // it keeps the name the token used.
        string? subject = User.FindFirst("sub")?.Value;

        if (subject is null)
        {
            // Authenticated, but the token lacks the claim this API needs.
            return Forbid();
        }

        EmployeeResponse? employee = await employees.FindBySubjectAsync(subject, cancellationToken);

        return employee is null ? NotFound() : Ok(employee);
    }
}
  • User is the ClaimsPrincipal the authentication step produced. It is available on ControllerBase and reflects the validated token, not the raw header.
  • Look up the caller by the subject claim rather than by anything the request body says. A body is caller-supplied; the subject claim survived signature verification.
  • A missing expected claim is a real case worth handling. The token was valid, so the caller is authenticated, but your API cannot act on their behalf without the identifier.
  • Forbid() produces a 403, meaning "I know who you are and this is not allowed". Unauthorized() produces a 401, meaning "I do not know who you are". Returning the wrong one sends a client into a pointless re-login loop.

Summary

  • Authentication establishes who the caller is; authorization is a separate step that follows it
  • A JWT payload is encoded, not encrypted, so decoding a token proves nothing about it
  • Validate signature, issuer, audience and lifetime. Each guards a different failure, and none is optional
  • Successful validation puts a ClaimsPrincipal on HttpContext.User, which is the only place your code should read identity from
  • Failed validation leaves the request anonymous and the 401 comes from authorization, so UseAuthentication must be registered before UseAuthorization

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Valid shape, rejected anyway

A caller reports that their token "looks fine" because a decoder website displays all its claims correctly, yet your API returns 401.

List four independent reasons the API could be right to reject it, and explain what the decoder website could not have told them.

Show solution

Four reasons: the signature does not verify against your configured key; the issuer is not the one you accept; the audience names a different service; the token has expired.

A decoder shows the payload because the payload is only encoded, not protected. It has no way to verify the signature, because verification needs the issuer's key. So "it decodes" and "it is genuine" are unrelated statements.

This is the most useful thing to internalise about JWTs. Readability is not a flaw in the design — the payload is meant to be readable — but it means that reading claims is never evidence of anything.

When diagnosing this for real, the handler's failure reason is logged server-side. Start there rather than from the caller's description.

Try it yourself

Watch each check fail on purpose

Point a test API at a development identity provider and get a working request. Then, one at a time, change the configured audience to a wrong value, then the issuer, then wait for a token to expire.

Each time, read the server log entry produced by the bearer handler and note the response the client receives.

Show solution

Each change produces a 401 with a WWW-Authenticate header describing the failure category, and a server log entry naming the specific check that failed. The client-facing response deliberately says little.

Doing this deliberately is worth the time because it builds the habit of reading the handler's own diagnostics. Authentication problems are nearly always configuration problems, and the handler usually states which check failed.

It also demonstrates that the checks are independent. A token can be perfectly signed and still be wrong for your service, which is the whole argument for validating the audience.

Saved in this browser only.