Using access tokens
You are choosing pictures for your photo book at the printer, and you open album 42. To show you its photos, the printer's backend has to ask the photo API for them, and it holds the credential to do that: the access token from the code exchange, good for ten minutes and for photos.read.
Obtaining that token took a trip through your browser and a direct exchange at the token endpoint. Using it looks like the easy part, one header on an ordinary HTTPS request, but the client still decides where the token goes, what it assumes about it, and what to do when the API says no.
Presenting a token
The printer sends the token in the Authorization header, using the Bearer scheme named by the token_type in its token response. All domains and tokens in these examples are fictional.
GET /albums/42/photos HTTP/1.1
Host: api.photos.example
Authorization: Bearer demo-access-token-7
This is the standard place for a bearer token, and every API that accepts bearer tokens must support it. Above all, it keeps the token out of the address.
The bearer token standard describes two other ways to send a token. One puts it in the query string:
GET /albums/42/photos?access_token=demo-access-token-7 HTTP/1.1
Host: api.photos.example
Now the token is part of the address, and addresses travel. Servers and proxies write full URLs to their logs, browsers keep them in history, caches store responses under them, and depending on the referrer policy, a page can pass its address to other sites in the Referer header. Each is a copy of the token where nobody treats it as secret, and current security guidance says clients must not send access tokens this way.
The other method sends access_token as a field in a form-encoded request body. It cannot be used with GET, and the standard says it should not be used unless the client cannot set the Authorization header, a limitation of some old browser environments that the printer's backend does not share. An API may refuse both alternatives, and a request that sends the token in more than one way at once is malformed.
Treating the token as opaque
To the printer, demo-access-token-7 is a string to store and send, even when the photo service issues tokens that can be decoded. A JWT access token decodes to readable claims, such as the account, the scope, and the expiry time, and it is tempting to use them.
But the format of an access token is an agreement between the authorization server and the API, not with the client. The photo service can switch from JWTs to random reference strings, encrypt its tokens, or rename a claim, and only its API needs to know. A client that reads the token breaks without warning on the day that happens, which is why the standard profile for JWT access tokens says clients must not inspect the token's content.
Everything the printer needs comes from the token response instead. scope says what was granted, and expires_in says how long the token lasts. Learning who you are is a different question, and OpenID Connect answers it with the ID token, which is addressed to the client and meant to be read.
The printer turns expires_in into a time as soon as the response arrives. Received at 09:00:00 with expires_in set to 600, the token expires at about 09:10:00 by the printer's own clock. The printer plans to replace it a little early, say at 09:09, so a request started just before expiry does not arrive just after it.
Early replacement is an optimization, not a guarantee. A token can stop working sooner, for example if you disconnect the printer at the photo service, so the printer still handles a rejected token on every request.
Reading the API's answer
When the photo API refuses a request for lack of a usable token, it explains in a WWW-Authenticate response header: the scheme, Bearer, followed by attributes. realm names the protected area and is informational. error is the part the client acts on.
If the printer sends no token at all, perhaps because a bug dropped the header, the answer is a bare challenge:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api.photos.example"
There is no error code because there was nothing to evaluate, and the bearer token standard says an API should not add error details when a request carried no credentials.
When a token was sent and rejected, the error code says what kind of problem it was. This header is wrapped here for display:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api.photos.example",
error="invalid_token",
error_description="The access token expired"
error_description is a note for developers. Like other optional error text, it is untrusted input, not a message to show you. The codes themselves lead to different next steps:
| Response | What it means | What the printer does |
|---|---|---|
| 401 with no error code | No usable credentials were found in the request. | Check that the request really carried the token. |
400 invalid_request | The request is malformed, for example a token sent two ways at once. | Fix the bug. Sending the same request again will not help. |
401 invalid_token | The token is expired, revoked, malformed, or otherwise not accepted. | Obtain a new access token and retry once. If the new one is rejected too, stop and mark the connection as needing attention. |
403 insufficient_scope | The token is valid but does not cover this operation. | Do not retry with the same token. Ask for more access only if the feature truly needs it. |
Retrying only once is deliberate. A token rejected for a reason other than age, such as a misconfigured API, will fail again however many new tokens the client fetches.
Suppose the printer adds a feature that saves your finished book as a new album in your photo library. Its token has only photos.read, so the API answers:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer realm="api.photos.example",
error="insufficient_scope",
scope="albums.create"
The scope attribute names what the operation needs, for the program rather than for display. A fresh token for the same grant would fail in the same way, and a refresh token cannot add scope. Getting albums.create means sending you through authorization again, where you can refuse.
Keeping the token where it belongs
Since anyone holding the token can use it, the printer is careful about where it sends one. The rule is short: only to the API the token was issued for, at the address the printer was configured with, over HTTPS with the server's certificate checked.
The easiest way to break that rule is to follow something a response suggests. A page of results might end with a link to the next page:
{
"photos": [ ... ],
"next": "https://api.photos.example/albums/42/photos?page=2"
}
Following this link with the token is fine, because it points to the same API. The printer checks that before adding the header: the link's scheme, host, and port must equal those of its configured API exactly. A check that the link merely begins with https://api.photos.example would also accept https://api.photos.example.attacker.example/, a different host entirely.
Redirects need the same care. Many HTTP libraries drop the Authorization header when a redirect leads to another host, but not all of them do. For calls that carry tokens, the printer either does not follow redirects automatically or checks each destination first. Addresses from anywhere else, such as a URL in a photo's description, never receive the token at all.
Last, the printer keeps the token out of its own records. Request logs that capture headers, error reports that attach the failing request, and analytics that record full URLs are common ways for a token to leak from a well-meaning client. A log line saying that a call failed with invalid_token tells a developer what they need without the token itself.
Once the request leaves the printer, the photo API has to decide what the token is worth. That decision starts with working out what kind of token it has received.