DEV Community

Cover image for Authorize Can't See Your Data
Kazem
Kazem

Posted on

Authorize Can't See Your Data

The first authorization bug I ever shipped wasn't a missing [Authorize] attribute. It was the opposite — everything had [Authorize], the tests were green, and it still let one user edit another user's stuff. The attribute was doing exactly what it promised. I just wanted it to promise something else.

That's the gap that trips people up: [Authorize] answers "is this user allowed to hit this endpoint." It has no idea "is this user allowed to touch this specific row." Those are two different questions, and ASP.NET Core gives you two different tools for them.

Authentication first, briefly

Before authorization, there's the "who are you" question, and the answer depends on who's asking:

Scenario Approach
Internal API behind corporate SSO OIDC via Entra ID / Keycloak, JWT bearer
Public API for third parties JWT bearer + a proper identity provider (Duende, Keycloak, Auth0)
Server-rendered app Cookie authentication + ASP.NET Core Identity
Service-to-service Client credentials flow, or mTLS inside the cluster
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.Authority = builder.Configuration["Auth:Authority"];
        o.Audience  = "sample-api";
        o.TokenValidationParameters = new()
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromSeconds(30)   // default 5 min is too generous
        };
    });
Enter fullscreen mode Exit fullscreen mode

Notice the ClockSkew. The default is five minutes, which is generous enough that an expired token can still get through for a while after it should have died. Worth tightening on anything that matters.

And don't hand-roll token issuance or password hashing. Use an identity provider, or ASP.NET Core Identity, which already does PBKDF2/Argon2-grade hashing, lockout, and 2FA. This isn't the place to prove you can implement crypto yourself.

Where policy-based authorization stops

Once you know who the user is, [Authorize] policies handle the "what role can hit what endpoint" layer well:

builder.Services.AddAuthorization(o =>
{
    o.AddPolicy("CanPublish", p => p.RequireClaim("permission", "posts.publish"));
    o.AddPolicy("Adults", p => p.Requirements.Add(new MinimumAgeRequirement(18)));
    o.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
});

[Authorize(Policy = "CanPublish")]
public async Task<IActionResult> Publish(Guid id) { ... }
Enter fullscreen mode Exit fullscreen mode

This works fine for "can this role publish posts at all." It falls apart the moment the question becomes "can this user publish this post." [Authorize] runs before the action method does anything. It never loaded the post, so it has nothing to check the user against — it can only see claims and roles, not data.

This is the same shape of problem as a Laravel policy, if you've worked in that world. A policy method takes the model instance and the user and decides together. [Authorize] alone can't do that, because by the time it runs, there's no instance yet.

Resource-based authorization

The fix is IAuthorizationService, called after the resource is loaded, with the resource passed in as an argument:

public sealed class PostAuthorizationHandler
    : AuthorizationHandler<OperationAuthorizationRequirement, Post>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext ctx,
        OperationAuthorizationRequirement requirement,
        Post resource)
    {
        if (resource.AuthorId.ToString() == ctx.User.FindFirstValue(ClaimTypes.NameIdentifier))
            ctx.Succeed(requirement);

        return Task.CompletedTask;
    }
}

// In the handler, once the resource is loaded:
var result = await authorization.AuthorizeAsync(User, post, Operations.Update);
if (!result.Succeeded) return Forbid();
Enter fullscreen mode Exit fullscreen mode

[Authorize] alone cannot express "the owner of this post," because it doesn't have the post. Anything instance-specific goes through IAuthorizationService: load the resource first, then ask the authorization service whether this user can do this operation on this specific instance. It's a deliberate two-step. The decision genuinely depends on data that only exists once you've queried it, so there's no way to collapse it into a single attribute.

I ran into this exact shape while building the approval workflow core for ProcessHub. Tasks get completed through a WorkflowTaskAppService, and the access check there has to look past the role claim: is this specific task assigned to this user, or does their role let them act on it. That can't happen at the attribute level. It happens after the ApprovalTask is loaded, against that instance — same reasoning as the Post example, different domain.

Anonymous should be the exception, not the default

The FallbackPolicy line in the snippet above is easy to skip past, but it's doing real work:

o.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
Enter fullscreen mode Exit fullscreen mode

Without it, any endpoint without an explicit [Authorize] is open by default. With it, the default flips: everything requires authentication unless you explicitly mark it [AllowAnonymous]. Forgetting [Authorize] on one new controller is the classic way to ship an open endpoint. A fallback policy turns that mistake from silent to loud — a forgotten attribute now means "user gets a 401," not "endpoint quietly accepts anyone."

It's a small config change, but it changes what kind of mistake is possible. Instead of relying on everyone remembering to lock every new controller, you're relying on someone remembering to unlock the few that should be public. That second failure mode is much easier to catch in review.

The checklist worth keeping around

A few of these are directly related to the resource-based point above, others are just things that are cheap to get right and expensive to get wrong later:

  • HTTPS + HSTS; UseHttpsRedirection
  • Fallback authorization policy; anonymous is opt-in
  • Rate limiting (AddRateLimiter) on auth and expensive endpoints
  • Secrets from a vault, never from appsettings.json or the repo
  • Parameterized queries only — FromSqlInterpolated, never string concatenation into FromSqlRaw
  • Don't log tokens, PII, or full request bodies; scrub structured log properties
  • app.UseCors() with an explicit origin list, never AllowAnyOrigin + credentials
  • Dependency scanning: dotnet list package --vulnerable --include-transitive in CI

None of these are exotic. Most authorization bugs I've seen didn't come from someone doing something clever wrong — they came from reaching for [Authorize] when the actual question needed a resource loaded first.

Top comments (0)