OAuth 2.0: grants, scopes and tokens
Who's who in OAuth, the grants that matter, and the errors a client should expect.
OAuth 2.0 answers a specific question: how can one application act on your behalf at another service, without you giving it your password? When a calendar app reads your Google Calendar, or a CI tool posts to your GitHub repository, OAuth is usually how. It's defined in RFC 6749, with a long list of later RFCs on top, and it has a reputation for being confusing. Most of the confusion goes away once you can see each request and response, which is what this lesson does with the OAuth 2.0 test server.
Four roles
Every OAuth flow involves the same four parties:
| Role | What it is | On this API |
|---|---|---|
| Resource owner | The person whose data it is | The test users apibee and jane |
| Client | The application that wants access | apibee-client (has a secret) and apibee-public (doesn't) |
| Authorization server | Checks who's who and issues tokens | /api/auth/oauth/authorizesign in to see and /token |
| Resource server | The API holding the data | /api/auth/oauth/protectedsign in to see and /userinfo |
The distinction between the two kinds of client matters through the rest of this tutorial. A confidential client runs on a server and can keep a secret. A public client runs on the user's device, as a single-page app or a mobile app, where any secret shipped with it can be extracted. Public clients therefore can't prove who they are with a secret, which is the problem PKCE solves in the next lesson.
Grants: ways of getting a token
A grant type is a recipe for getting an access token. There are several, and choosing the right one is half of using OAuth well:
| Grant | Use it for | Status |
|---|---|---|
| Authorization code with PKCE | Any app acting for a user: web, mobile, single-page | The default choice. Lesson 6 |
| Client credentials | A service acting for itself, with no user | Fine for server-to-server |
| Refresh token | Getting a new access token without the user | Used alongside the others. Lesson 4 |
| Password | The client collects the user's password and sends it | RFC 9700 says it must not be used. This server supports it for testing |
| Implicit | Tokens returned straight in the browser URL | Deprecated. This server refuses it with unsupported_response_type |
If an app you test uses the password grant or the implicit flow, report that before any other test. Both were common ten years ago, and both are now recognised as unsafe: the first hands the user's password to the client, defeating the point of OAuth, and the second exposes tokens in URLs and browser history.
Client credentials, end to end
The simplest grant has no user and no browser. The client authenticates with its ID and secret, here as HTTP Basic, and asks for a token:
$ curl -i -X POST https://api.testingapis.com/api/auth/oauth/token \
-u apibee-client:apibee-secret \
-d grant_type=client_credentials \
-d scope=read
HTTP/1.1 200 OK
Cache-Control: no-store
Pragma: no-cache
{"access_token":"oat_e8afbca2b3ecdd152cebae156aa6065cbb3ecf0f","token_type":"Bearer","expires_in":3600,"scope":"read"}Note the Cache-Control: no-store. RFC 6749 requires it on every response containing a token, so that no proxy or browser cache keeps a copy. It's an easy header to forget and an easy one to test. The token server here also accepts JSON, so you can send the same request from the page:
/api/auth/oauth/token{"grant_type":"client_credentials","client_id":"apibee-client","client_secret":"apibee-secret","scope":"read"}Then call the resource server with the token:
$ curl https://api.testingapis.com/api/auth/oauth/protected \
-H "Authorization: Bearer oat_e8afbca2b3ecdd152cebae156aa6065cbb3ecf0f"
{"message":"You accessed an OAuth-protected resource.","client_id":"apibee-client","sub":"apibee-client","scope":"read","expires_in":3599}The access token is an opaque string starting oat_, not a JWT. OAuth doesn't say what an access token looks like, and plenty of providers use opaque ones. A client that tries to decode its access token is relying on something the provider never promised.
Scopes
A scope limits what a token may do. The client asks for scopes; the server decides what to grant and says so in the scope field of the token response. Always read that field rather than assuming you got what you asked for. On this server:
- Asking for
readgives a token withscope: "read". - Asking for nothing gives every scope the client is allowed:
read write profile email. Defaulting to everything is convenient and generous; many servers default to the minimum instead. - Asking for
admin, which the client may not have, gives 400 withinvalid_scopeand the list of allowed scopes.
Scopes change what the resource server returns, not just whether it answers. The same user, two tokens:
# A token for user jane with scopes "read profile"
{"sub":"jane","name":"Jane Doe","preferred_username":"jane","email":null,"email_verified":null}
# The same user, with "read profile email"
{"sub":"jane","name":"Jane Doe","preferred_username":"jane","email":"jane.doe@example.com","email_verified":true}Without the email scope the email comes back as null rather than being left out. A client that treats nullas "this user has no email" will show the wrong thing; the truth is "this token may not see it". That's a good case for a test, and a good question for the API's designers.
OAuth errors
OAuth has its own error format: a JSON object with error and an optional error_description. The codes are fixed by the RFCs, so clients can act on them. Every row here was triggered on this server:
| Request | Status | error |
|---|---|---|
| Wrong client secret | 401 | invalid_client |
| Scope the client may not have | 400 | invalid_scope |
| Public client asking for client_credentials | 400 | unauthorized_client |
grant_type=magic | 400 | unsupported_grant_type |
| Used, expired or unknown code or refresh token | 400 | invalid_grant |
response_type=token (implicit) | 400 | unsupported_response_type |
| Revoked or unknown token at the resource server | 401 | invalid_token, also in WWW-Authenticate |
| Client credentials token at /userinfo | 403 | insufficient_scope |
Note which are 400 and which are 401. Most token endpoint failures are 400, even a spent refresh token, which surprises people used to 401 meaning "bad credentials". Only a failed client authentication is 401. A client that only handles 401 will treat an expired refresh token as an unknown error instead of sending the user to log in.
Introspection and revocation
Two endpoints let a client or resource server ask about a token and cancel it. Introspection (RFC 7662) reports whether a token is active and what it may do. Revocation (RFC 7009) cancels it:
$ curl -X POST https://api.testingapis.com/api/auth/oauth/introspect -d token=oat_908abb65…
{"active":true,"scope":"read profile","client_id":"apibee-client","username":"jane","sub":"jane","token_type":"Bearer","iat":1791714045,"exp":1791717645}
$ curl -X POST https://api.testingapis.com/api/auth/oauth/revoke -d token=oat_908abb65…
{"revoked":true,"message":"Token revoked."}
$ curl -X POST https://api.testingapis.com/api/auth/oauth/introspect -d token=oat_908abb65…
{"active":false}After revoking, the resource server refused the token with 401 straight away. Introspection doesn't distinguish between revoked, expired and never-existed: all three are {"active":false}, with status 200. That's deliberate, so the endpoint can't be used to probe which tokens exist. Revocation returns 200 even for unknown tokens, for the same reason. A test that expects 404 for an unknown token has misread the RFC.
What to test on any OAuth server
- Every token response has
Cache-Control: no-store. - Each error case in the table returns the RFC's error code and status.
- The granted scope is what the client should get, never more than it asked for.
- A revoked token stops working at the resource server immediately, and so do its refresh tokens.
- The password and implicit grants are disabled, unless there's a documented reason.
Exercise
Use the password grant as jane / jane123 with scope read profile, and save both tokens. Call /userinfo. Revoke the refresh token, not the access token, then check: does the access token still work? Can you still refresh? Write down what a user would experience in each case, and whether revoking one token should affect the other.