Resource-Based Authorization
Resource-based authorization is how you prevent horizontal privilege escalation. Learn per-resource checks, tenant boundaries, and how to enforce ownership without leaking existence or creating N+1 auth calls.
Roles Are Not Enough for Real Systems
Most real authorization bugs are not “user is not admin”.
They are:
- user accesses another user's resource
- tenant A reads tenant B data
- support role can mutate resources it should only view
This is horizontal privilege escalation, and it is the most common breach class in multi-tenant APIs.
Production rule:
Authorization must be tied to the resource, not just the identity.
Real Production Incident
Symptoms:
- Customer reports seeing another customer's invoice.
- Logs show authenticated requests with valid user tokens.
- No admin role involved.
Root cause:
- Endpoint authorized by role only (User role).
- Data query used invoice id without tenant/user scoping.
- Any user who guessed or obtained another invoice id could access it.
This is a classic missing resource-based authorization check.
Symptom → Cause → Diagnosis → Fix
Symptom:
- cross-tenant or cross-user data exposure
- “it only happens for certain IDs”
- security incident with valid auth logs
Cause:
- authorization checks not scoped to resource
- database queries missing tenant/owner filter
- relying on client-provided userId/tenantId in request payload
- inconsistent checks across endpoints
Diagnosis:
- Attempt access to a resource owned by another user in staging.
- Audit queries: are they scoped by tenant_id and/or owner_id?
- Review endpoints: do they check ownership or only roles?
- Inspect logs for resource identifiers and user identifiers (carefully, no PII leaks).
Fix:
- Enforce resource-based authorization consistently.
- Apply tenant/owner filters in data access layer.
- Avoid separate “fetch then authorize” patterns that leak existence.
- Ensure error semantics do not reveal sensitive existence information.
Anti-Pattern: Role-Only Authorization for Owned Resources
Example:
[Authorize(Policy = "User")]
[HttpGet("/v1/invoices/{id}")]
public async Task<IActionResult> Get(Guid id)
{
var invoice = await db.Invoices.FindAsync(id);
return Ok(invoice);
}
This says:
“If you are a user, you can fetch any invoice by ID.”
That is a breach.
Correct Pattern: Scope Data Access by Tenant/Owner
The simplest safe pattern is to scope the query:
var tenantId = User.FindFirst("tenant_id")!.Value;
var invoice = await db.Invoices
.AsNoTracking()
.Where(x => x.Id == id && x.TenantId == tenantId)
.SingleOrDefaultAsync();
Now cross-tenant access fails naturally.
Then decide response semantics:
- If not found, return 404.
- If you need explicit 403 vs 404, enforce policy intentionally.
Production rule:
Data access layer is part of authorization enforcement.
Resource-Based Authorization With Handlers
For complex rules, use resource-based authorization handlers.
Conceptually:
- load resource (or minimal resource projection)
- authorize against user + resource
- return allowed/denied
But beware:
If you load full resource first, you may leak data before authorization.
Load only the minimal fields needed for auth decisions.
Production rule:
Authorize before returning sensitive fields.
Avoid N+1 Authorization Calls
If you authorize per item in a list endpoint, you can create:
- N+1 database calls
- latency spikes
- throughput collapse under load
Bad pattern:
Fetch list, then authorize each item via separate query.
Correct:
Push authorization into query:
- filter by tenant/owner
- include only resources user can access
- optionally apply role-based expansions (e.g., support can see more)
Production rule:
Authorization should be queryable, not loop-based.
Existence Leaks: 403 vs 404
Resource-based authorization interacts with information disclosure.
If you return 403 for unauthorized resources, attackers can confirm existence.
If you return 404, you hide existence but may confuse legitimate clients.
Pick a policy:
- For public identifiers: 403 may be fine.
- For sensitive resources: 404 masking may be required.
Production rule:
Do not decide this ad-hoc per endpoint. Define a consistent rule.
Tenant Boundaries Are Non-Negotiable
In multi-tenant systems:
- tenant scoping must be enforced everywhere
- not “most places”
- not “only for reads”
Write operations must also be scoped:
- updates must include tenant predicate
- deletes must include tenant predicate
- unique constraints may need tenant_id included
Production rule:
If tenant scoping is missing in one endpoint, your tenant isolation is broken.
Security Notes
- Never trust tenantId/userId from request payload for authorization.
- Use claims from validated tokens.
- Enforce scoping in persistence queries.
- Consider defense in depth: database row-level security (where available), constraints, and strict service-layer checks.
Operational Notes
Monitoring:
- Track authorization failures (403/404 masking) by endpoint.
- Alert on unusual access patterns (many 404s for sequential IDs).
- Monitor list endpoints for latency spikes due to authorization design.
Rollout:
- Tightening resource authorization can break clients relying on “too much access”.
- Canary and measure access-denied rates.
Rollback:
- If authorization change blocks legitimate business flows, rollback quickly.
- Do not weaken authorization permanently as a hotfix; fix the rule and re-ship.
Runbooks:
- Have an incident runbook for suspected cross-tenant leaks:
- identify impacted tenant/user
- audit access logs
- rotate credentials if needed
- patch and validate isolation
Checklist
- Owned/tenant resources are protected with resource-based authorization.
- Data access queries include tenant/owner predicates.
- No authorization decisions rely on client-provided userId/tenantId.
- List endpoints avoid N+1 authorization patterns.
- Existence leak policy (403 vs 404) is defined and consistent.
- Authorization failures and suspicious access patterns are monitored.
- Authorization tightening is canaried and rollbackable.
- Runbook exists for cross-tenant exposure incidents.