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.
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:
- Authentication: Who is this request from?
- Authorization: Is this person allowed to do this?
Do not mix these two. Each one has its own error code:
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.
How does the server trust the token? Step by step:
- The identity server (Identity Provider) signs the token. With its own private key.
- 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.
- Then it checks a few fields. The issuer (iss), the audience (aud) and the expiry time (exp).
- It does not go to the database. Everything is inside the token itself. This is why it is fast. We call this stateless.
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:
- The user clicks “sign in”. The app sends them to the identity server’s page.
- The user enters their password only there. The identity server returns a short-lived code to the app.
- 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.
- The app calls the API with the Access Token. The API only checks the token.
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.
- It is defined in one place. If the refund rule changes, only one place changes.
- 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.
- It can have several conditions. For example, a specific claim and a specific role together.
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();
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?
- The API service does not look at the database to check the token. It only checks the signature and the expiry.
- So the token is still valid. It works until the end of its life.
- Disabling in the database has no effect. The same stateless advantage is a problem here.
The options, from simple to exact:
- 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.
- 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.
- 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.
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
- Authentication before authorization. In the Pipeline, first the UseAuthentication method, then UseAuthorization.
- Check the token’s audience. A token made for another API must not be accepted here.
- 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.
- Check authorization on the server. Hiding a button in the UI is not security.
- Check data ownership. Having the “customer” role means permission to see their own orders, not all orders.
- 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
- Authentication says who the user is. Authorization says what they are allowed to do.
- An anonymous user gets 401. A user without permission gets 403.
- A JWT token is signed, not encrypted. The API checks it without the database.
- The password only reaches the identity server. The suggested way is Authorization Code with PKCE.
- Define authorization rules with a name (Policy). For “only your own data”, you need resource-based authorization.
- A JWT token cannot be revoked right away. A short life, a Refresh Token and a block list help.
- In the browser, keep the token in an HttpOnly cookie, not in localStorage.