Published 17 Oct 2026

403 or 404, and Other Authorization Calls to Make on Purpose

A 403 tells the caller the thing exists. Which status you return on a denial, where identity comes from, and whether endpoints depend on how roles are issued are all decisions most APIs make by default.

Two words get mixed up constantly:

  • Authentication: who are you? Failing it is 401 Unauthorized. The name is a historical accident. It means unauthenticated.
  • Authorization: are you allowed to do this? Failing it is 403 Forbidden.

Getting those right is the easy part. The harder part is a handful of decisions that sit on top of them, and that most APIs make by default instead of on purpose.

403 or 404?

A requester asks for a ticket that belongs to someone else. You return a 403. That is correct, and it also tells the caller the ticket exists.

For random GUIDs, that is usually fine. For anything guessable (sequential IDs, email addresses, usernames), returning 404 instead hides whether the thing exists at all. GitHub does exactly this for private repositories.

Pick deliberately, and pick the same answer everywhere. An API that returns 403 on one endpoint and 404 on another for the same condition is leaking more than either choice would on its own.

Never trust identity in the body

A requesterId field on a create request lets a caller claim to be someone. You can patch it with a rule (“requesters can only create for themselves”), but the cleaner design is not to accept it from requesters at all. Take identity from the token. Only let privileged roles name someone else.

Removing that field from the request is a breaking change, which is exactly the kind of thing that waits for v2. Which is one more reason to version from day one.

Roles, scopes, and claims

Roles answer “what kind of user is this?” Real identity providers also issue scopes (what this app may do on the user’s behalf) and arbitrary claims (department, tenant, subscription tier).

Write your endpoints against named policies, not against roles directly. RequireAuthorization(Policies.Agent) keeps working when “agent” stops being a role from a dev tool and becomes a claim from Entra ID. The endpoint never knows the difference.

API keys or tokens?

A key identifies a client. It can’t express scopes, expiry, or acting on a user’s behalf, and it is valid until someone revokes it. Keys are fine for a dashboard or a nightly job. For anything bigger, the OAuth 2.0 client credentials flow gives a service a short-lived token, and the same JWT bearer validation you already have handles it.

user-jwts is for development

dotnet user-jwts is a fast way to test auth locally, nothing more. In production, tokens come from an identity provider and your API only validates them. Because AddJwtBearer() reads its settings from configuration, moving to a real provider is a configuration change, not a code change. Keep it that way.

The point

Status codes are the mechanics. The decisions that matter are what you reveal on a denial, where identity comes from, and whether your endpoints depend on how roles are issued today. Make each one once, on purpose, and apply it everywhere.