Blog Backend & APIs
SECURITY

OAuth 2.0 Using ASP.NET Core

Securing an ASP.NET Core API with OAuth 2.0 without the framework magic feeling like a black box — grant types, tokens, and what's actually happening at each step.

Mar 24, 2023

OAuth 2.0 gets treated like a black box a lot of the time — copy a middleware snippet, add [Authorize] to a controller, move on. That works fine until a client asks why their access token expired mid-session, or a partner integration needs client_credentials instead of the browser-based flow you already built. Here is what is actually happening underneath, and how it maps onto ASP.NET Core.

Authorization Code vs. Client Credentials

The Authorization Code grant is for anything with a human in the loop — a web or mobile app redirects the user to an identity provider (Entra ID, IdentityServer, Auth0, or whatever you are running), the user authenticates and consents, and the app exchanges a short-lived authorization code for tokens. Add PKCE on top for public clients like SPAs and mobile apps, since they cannot keep a client secret confidential.

Client Credentials is for service-to-service calls with no user involved — a background job or another API authenticates with its own client ID and secret directly against the token endpoint and gets back an access token scoped to that service's own permissions. If you catch yourself inventing a fake "service account user" just to get a token, that is a sign you actually want client_credentials.

What Is Actually Inside a Token

An access token — usually a JWT, though OAuth does not strictly require that format — is a signed, time-limited claim that says "this bearer is allowed to do X." It is opaque to the client; the client's job is to attach it, not to make security decisions based on its contents. A refresh token is longer-lived and used only against the token endpoint to get a new access token without forcing a fresh login, and it should be stored more carefully than the access token and be revocable server-side.

The claims worth caring about are iss (who issued it), aud (who it is valid for), exp (when it expires), sub (the subject), and scope or roles (what it is allowed to do). Validating all of those — not just checking the signature — is what stops a token minted for a different API from being replayed against yours.

Wiring Up JWT Bearer Authentication

In ASP.NET Core this is a handful of lines in Program.cs: register the JWT Bearer scheme, point it at the identity provider's authority, and set the validation parameters explicitly rather than trusting the defaults.

csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://login.example.com/";
        options.Audience = "api://inventory-service";
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromMinutes(2)
        };
    });

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("CanReadOrders", policy =>
        policy.RequireClaim("scope", "orders.read"));
});

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

Protecting Endpoints and Checking Scopes

[Authorize] alone only checks that a valid token was presented — it does not check what that token is allowed to do. For anything beyond "is this caller authenticated," apply a policy that inspects the scope or role claims explicitly rather than relying on convention.

csharp
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    [HttpGet]
    [Authorize(Policy = "CanReadOrders")]
    public IActionResult GetOrders()
    {
        var userId = User.FindFirst("sub")?.Value;
        return Ok(new { message = $"Orders for {userId}" });
    }
}
  • Signature — was the token issued by a key we trust?
  • Issuer (iss) — did it come from the expected identity provider?
  • Audience (aud) — was it minted for this API specifically?
  • Expiry (exp) — is it still valid right now?
  • Scope/roles — does it grant the specific permission this endpoint needs?

ASP.NET Core's default ClockSkew for token expiry validation is 5 minutes, which is generous for distributed systems with unsynced clocks. If you tighten it (as in the example above) or validate exp yourself in custom middleware, account for that skew explicitly or you will see intermittent 401s right around token expiry.

Wrapping Up

Most OAuth bugs we have debugged in production were not cryptography problems — they were a missing audience check, a policy that only verified authentication instead of scope, or a refresh flow that never got wired up so users got logged out every hour. Get the grant type right for the caller and validate the whole claim set, not just the signature, and the rest tends to be plumbing.

OAuth 2.0 ASP.NET Core Security

More from the blog

View all articles

Read something that fits your problem?

Tell us what you're building. We'll come back with an honest, scoped recommendation — not a sales pitch.

Start a Project