Levelwise
English
ASP.NET Core

Authentication and Authorization

Authentication asks "who are you?". Authorization asks "are you allowed to do this?". In APIs, the user is usually known by a JWT token, and Policies decide who can do what.

Not reviewedWritten with AI helpReading time: 17 minOnline shop API exampleC# and .NET 10 code

Author: bezzad

The problem: two different questions

In our shop, a customer sees their orders. A support agent can refund an order. A manager sees everything. For every request, the API must answer two separate questions:

  1. Authentication: Who is this request from?
  2. Authorization: Is this person allowed to do this?

Do not mix these two. Each one has its own error code:

RequestToken or no tokenAuthenticationWho are you?AuthorizationAre you allowed?Refund200 OK401I don't know you403I know you, but no
If we do not know who the user is, the answer is 401. If we know who they are but they are not allowed, the answer is 403.
A simple rule: Code 401 means “introduce yourself”. Code 403 means “I know you, but no”. In the HTTP standard, the name of 401 is “Unauthorized”, but its real meaning is “not authenticated”.

What is a JWT token?

After sign-in, the user gets a token. In every request, they send it in the Authorization header with the word Bearer. The most common format for this token is JWT.

eyJhbGciOi....eyJzdWIiOiI0Mi....SflKxwRJSMeK...Header"alg": "RS256""typ": "JWT"Signing algorithmPayload"sub": "42""aud": "shop-api""exp": 1767225600"permission": "orders.read"Everyone can read itSignatureIdentity server's private keyOver header and payloadShows any payload change
Three parts are separated by dots. The middle part is only Base64, so anyone can read it.

How does the server trust the token? Step by step:

  1. The identity server (Identity Provider) signs the token. With its own private key.
  2. The API service only checks the signature. With the public key of that same identity server. If someone changes one letter of the token, the signature is no longer valid.
  3. Then it checks a few fields. The issuer (iss), the audience (aud) and the expiry time (exp).
  4. It does not go to the database. Everything is inside the token itself. This is why it is fast. We call this stateless.
Signed, not encrypted: Anyone can read the content of a JWT. So never put a password, a card number or sensitive information inside it.

Where do we get the token? OAuth2 and OpenID Connect

The API itself must not take the user’s password. We give this job to an identity server. For example Keycloak, Microsoft Entra ID or Duende IdentityServer. Two standards define this conversation:

  • The OAuth2 protocol: It is about access. It gives the application an Access Token to call the API.
  • The OpenID Connect (OIDC) protocol: It is a layer on top of OAuth2. It is about identity. It also gives an ID Token that says who the user is.

For web and mobile apps, the suggested way is Authorization Code with PKCE:

UserShop appIdentity serverOrder service1. Password, only to the identity server2. A short-lived code3. Code and one-time secretAccess Token, ID Token4. Request with the access tokenOnly checks the token
The password only reaches the identity server. The API only sees the Access Token.
  1. The user clicks “sign in”. The app sends them to the identity server’s page.
  2. The user enters their password only there. The identity server returns a short-lived code to the app.
  3. The app exchanges the code for a token. Together with a one-time secret (PKCE). This way, if someone steals the code on the way, they cannot use it.
  4. The app calls the API with the Access Token. The API only checks the token.
Which token is for whom? The ID Token is for the app itself, so it knows who the user is. The Access Token is for the API. Never send the ID Token to the API.

Code: checking the token in the API

The needed library is Microsoft.AspNetCore.Authentication.JwtBearer. When you set the Authority, this library downloads the public keys of the identity server by itself, and checks the signature, issuer, audience and expiry:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://login.shop.example"; // the identity provider
        options.Audience = "shop-api";                    // tokens must be made for this API
        options.MapInboundClaims = false;                 // keep claim names like "sub" as they are
    });

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("CanRefund", p => p.RequireClaim("permission", "orders.refund"));

var app = builder.Build();

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

app.MapGet("/products", (ShopDb db, CancellationToken ct) => db.Products.ToListAsync(ct))
   .AllowAnonymous();

app.MapPost("/orders/{id:guid}/refund", (Guid id, RefundService refunds, CancellationToken ct) =>
        refunds.RefundAsync(id, ct))
   .RequireAuthorization("CanRefund");

app.Run();

A named authorization rule (Policy)

Instead of writing “if the role is support” in every endpoint, we define the rule once, with a name. We call this rule a Policy.

  1. It is defined in one place. If the refund rule changes, only one place changes.
  2. Its name says the action, not the role. The name “CanRefund” is better than “SupportOrAdmin”. Tomorrow, a new role may also get permission to refund.
  3. It can have several conditions. For example, a specific claim and a specific role together.
Why is a Claim better than a Role? A Role is a general label. A Claim is an exact piece of information about the user, like “is allowed to refund”. Finer rules are easier to write with Claims.

Authorization based on data: “only your own order”

A normal Policy only sees the user. But the rule “a customer only sees their own order” also needs the order itself. If we do not check this, customer 42 can see the order of customer 43 by changing the number in the address. This hole is called IDOR, and it is one of the most common holes in APIs.

The solution is Resource-based authorization:

public sealed class OwnsOrderRequirement : IAuthorizationRequirement;

public sealed class OwnsOrderHandler : AuthorizationHandler<OwnsOrderRequirement, Order>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, OwnsOrderRequirement requirement, Order order)
    {
        var userId = context.User.FindFirstValue("sub");
        if (userId == order.CustomerId.ToString())
            context.Succeed(requirement);

        return Task.CompletedTask;
    }
}

// Program.cs
builder.Services.AddSingleton<IAuthorizationHandler, OwnsOrderHandler>();

app.MapGet("/orders/{id:guid}", async (Guid id, ShopDb db, ClaimsPrincipal user,
    IAuthorizationService auth, CancellationToken ct) =>
{
    var order = await db.Orders.FindAsync([id], ct);
    if (order is null) return Results.NotFound();

    var result = await auth.AuthorizeAsync(user, order, new OwnsOrderRequirement());
    return result.Succeeded ? Results.Ok(order) : Results.NotFound(); // do not reveal it exists
}).RequireAuthorization();
Why 404 instead of 403? If we return 403 for other people’s orders, the attacker learns that this order number exists. With 404, there is no visible difference between “it does not exist” and “it is not yours”.

The big problem with JWT: revoking

The security manager disables the account of a bad user in the database. But this user still has a one-hour token. What happens?

  1. The API service does not look at the database to check the token. It only checks the signature and the expiry.
  2. So the token is still valid. It works until the end of its life.
  3. Disabling in the database has no effect. The same stateless advantage is a problem here.
Time ←User disabledOne-hour tokenStill has access, until the hour endsFive-minute tokenA bitRefresh denied, access cut
With a short token life, the gap between disabling and cutting access becomes short.

The options, from simple to exact:

  1. A short life for the Access Token, plus a Refresh Token. The access token is valid for only a few minutes. For a new token, the app sends the Refresh Token to the identity server. The server checks the user’s status at that moment.
  2. A block list. Keep the token ID (the jti field) or the user ID in Redis until the expiry time. Check it in every request. This fits emergency cases.
  3. A Reference Token. The token is only an ID. The API asks the identity server every time. It is exact, but it adds more load and delay.
Refresh Token rotation: Each time a Refresh Token is used, give a new Refresh Token and revoke the old one. If a revoked token arrives again, it means it was stolen. Revoke all tokens of that session.

Where to keep the token in the browser?

DangerousStored in localStorage

  • Any JavaScript code on the page can read it.
  • If the site has an XSS hole, the attacker steals the token.

SaferHttpOnly cookie

  • JavaScript code cannot read it.
  • With the Secure setting, it is sent only over HTTPS.
  • With the SameSite setting, most CSRF attacks are blocked.

For SPA apps, the BFF (Backend for Frontend) pattern is common. A small server next to the SPA keeps the tokens and talks to the browser only with an HttpOnly cookie.

Important rules

  1. Authentication before authorization. In the Pipeline, first the UseAuthentication method, then UseAuthorization.
  2. Check the token’s audience. A token made for another API must not be accepted here.
  3. Set the default to “closed”. It is better if all endpoints need authentication and only a few are clearly open. You do this with the SetFallbackPolicy method in the same AddAuthorizationBuilder.
  4. Check authorization on the server. Hiding a button in the UI is not security.
  5. Check data ownership. Having the “customer” role means permission to see their own orders, not all orders.
  6. Keep the Access Token life short. A few minutes, together with a Refresh Token.

Common mistakes

Mistake Result The right way
Putting sensitive information in a JWT Anyone who sees the token can read it. Only the ID and the needed claims.
Not checking the audience (aud) A token for another service also works here. Set the Audience.
No ownership check A customer sees other people’s orders by changing the ID. Resource-based authorization.
A one-day token with no way to revoke it A disabled user has access for up to a day. Short life and a Refresh Token, or a block list.
Keeping the token in localStorage With one XSS hole, the token is stolen. An HttpOnly cookie or the BFF pattern.
Returning 403 instead of 401 (or the other way around) The client does not know if it must sign in again. Anonymous: 401. Not allowed: 403.
Authorization rules spread across the code When a rule changes, some places are forgotten. A named rule (Policy).

Summary in seven lines

  1. Authentication says who the user is. Authorization says what they are allowed to do.
  2. An anonymous user gets 401. A user without permission gets 403.
  3. A JWT token is signed, not encrypted. The API checks it without the database.
  4. The password only reaches the identity server. The suggested way is Authorization Code with PKCE.
  5. Define authorization rules with a name (Policy). For “only your own data”, you need resource-based authorization.
  6. A JWT token cannot be revoked right away. A short life, a Refresh Token and a block list help.
  7. In the browser, keep the token in an HttpOnly cookie, not in localStorage.