Enforcing access at an API
Two requests reach the photo API a second apart. Both come from the printer, and both carry the same valid token for user-2048 with photos.read. All domains, accounts, and tokens in these examples are fictional.
GET /albums/42/photos HTTP/1.1
Host: api.photos.example
Authorization: Bearer demo-access-token-7
GET /albums/43/photos HTTP/1.1
Host: api.photos.example
Authorization: Bearer demo-access-token-7
Album 42 is yours. Album 43 belongs to someone else. Everything the earlier lessons checked is identical for both requests: the issuer, the signature, the audience, the expiry, and the scope. Only one of them should succeed, and nothing in the token can tell the API which.
Three questions in order
The photo API's decision has three parts, and each depends on the one before it:
- Is the token valid here? These are the checks from Token formats and validation, or an equivalent answer from the authorization server. If not, the answer is 401.
- Does the token's scope cover this operation? Listing an album's photos needs
photos.read. If not, the answer is 403. - May this subject act on this object? Is
user-2048allowed to see album 43? If not, the photo API answers 404 rather than 403, so the refusal does not reveal that album 43 exists.
The first two need only the token and the route. The third needs the API's own data about who owns what and what has been shared, which is why it is the check most often missing.
Mapping operations to scopes
The photo API keeps a table of every operation it offers and the scope each one requires:
| Operation | Required scope |
|---|---|
GET /photos | photos.read |
GET /albums/{albumId}/photos | photos.read |
POST /albums | albums.create |
DELETE /photos/{photoId} | photos.delete |
The table is the API's policy, written down, and two habits make it trustworthy.
The first is deny by default. Every route appears in the table, and a route that does not appear is refused. If a developer adds GET /albums/{albumId}/export and forgets the table, the safe outcome is that nobody can call it until someone decides which scope it needs, not that every valid token can.
The second is enforcement in one shared place, such as middleware that runs before any route handler. A check written at the top of each handler depends on every developer remembering it, every time. A check in the shared path applies to routes nobody has thought about yet.
When a token lacks the required scope, the API answers 403 with insufficient_scope and names the scope the operation needs, the response the Using access tokens lesson showed from the client's side.
Checking the object
An API that checks the operation but not the object has broken object-level authorization: anyone allowed to read some album can read every album, just by changing the number in the URL. It is one of the most common serious flaws in real APIs, because each request looks legitimate. The token is valid, the scope is right, and the code that loads an album by its identifier works exactly as written.
The fix is to ask about the object on every request, using the subject from the validated token. Here is a sketch of the album route in TypeScript, written for an invented web framework:
// Each route declares the scope it requires. Shared middleware validates
// the token and checks that scope before any handler runs, and it refuses
// any route that declares none.
app.get('/albums/:albumId/photos', { scope: 'photos.read' }, async (req, res) => {
// null when the token was issued to a client acting for itself
const user = req.auth.user;
if (!user) return res.status(403).end();
// Search only albums this user owns or that are shared with them, never by ID alone.
const album = await albums.findVisibleTo(user.id, req.params.albumId);
if (!album) return res.status(404).end();
return res.json(await photos.listForAlbum(album.id));
});
The important line is the lookup. findVisibleTo searches only the albums that user-2048 owns or that someone has shared with that account, so album 43 is simply not found. Writing the query this way, rather than loading album 43 and comparing its owner afterwards, makes the safe version the only version. There is no code path that holds someone else's album and then forgets to check. The user ID comes from the validated token, never from a parameter, header, or body field the client controls.
The check above it handles a different case. A token from the client credentials grant represents the client alone. When the printer obtains one for itself, the token says the printer is asking and says nothing about any person. If the photo API accepts such tokens, it must never let one stand in for a user, and the danger is subtler than it looks. In a JWT access token, the subject of a client-only token identifies the client. If a client could register under an identifier that matched a person's subject, say user-2048, its own token would carry "sub": "user-2048", and a careless ownership check would pass.
Current security guidance says authorization servers should not let clients choose identifiers that could be confused with people's, and where that cannot be avoided, they must give APIs another reliable way to tell the two kinds of token apart. The API has to use that distinction. In the sketch, the middleware leaves req.auth.user empty for a client-only token, and the route refuses it.
Choosing the response
The status code tells the client what kind of refusal it received. It can also tell an attacker things the API meant to keep private. The bearer token standard defines the first two rows below. The others are the photo API's own design, and a common one:
| Situation | Response |
|---|---|
| No token, or a token that fails validation | 401, with invalid_token when a token was sent |
| A valid token without the operation's scope | 403, with insufficient_scope |
| A client-only token on a route that needs a user | 403 |
| An album that does not exist, or that this user may not see | 404, the same for both |
| An album this user can see but may not change, such as deleting a photo from an album shared for viewing | 403 |
The 404 row is deliberate. If the API answered 403 for album 43 and 404 for album 9999, anyone could probe numbers and learn which albums exist from the difference. Answering 404 for both means a caller learns about albums only when it can already see them. The same thinking applies to the rest of the response: a refusal that takes noticeably longer, or carries a different message, gives away what the status code hides. When the caller can already see the object, as in the last row, 403 hides nothing and is the clearer answer.
Each decision is worth recording, because refusals are often the first sign of misuse. A useful record holds the time, a request ID, the route, the subject, the client ID, the token's jti when it has one, the scope required, the outcome, and a reason category such as insufficient_scope or object_not_visible. It never holds the token or the Authorization header. A burst of object_not_visible refusals as one client walks through album numbers is exactly the pattern those records exist to show.
Every check here happens inside a single request, against a token that lasts minutes. The longer-lived credential behind it, the refresh token, is managed somewhere else entirely, at the authorization server.