TestingAPIs is still being built. If something breaks, email support@testingapis.com.

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:

RoleWhat it isOn this API
Resource ownerThe person whose data it isThe test users apibee and jane
ClientThe application that wants accessapibee-client (has a secret) and apibee-public (doesn't)
Authorization serverChecks who's who and issues tokens/api/auth/oauth/authorizesign in to see and /token
Resource serverThe 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:

GrantUse it forStatus
Authorization code with PKCEAny app acting for a user: web, mobile, single-pageThe default choice. Lesson 6
Client credentialsA service acting for itself, with no userFine for server-to-server
Refresh tokenGetting a new access token without the userUsed alongside the others. Lesson 4
PasswordThe client collects the user's password and sends itRFC 9700 says it must not be used. This server supports it for testing
ImplicitTokens returned straight in the browser URLDeprecated. 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:

Terminal
$ 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"}
This example uses endpoints for signed-in members.Sign inCreate a free account

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:

POST/api/auth/oauth/token
{"grant_type":"client_credentials","client_id":"apibee-client","client_secret":"apibee-secret","scope":"read"}
This request uses an endpoint for signed-in members.Sign inCreate a free account

Then call the resource server with the token:

Terminal
$ 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}
This example uses endpoints for signed-in members.Sign inCreate a free account

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 read gives a token with scope: "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 with invalid_scope and the list of allowed scopes.

Scopes change what the resource server returns, not just whether it answers. The same user, two tokens:

/api/auth/oauth/userinfo
# 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}
This example uses endpoints for signed-in members.Sign inCreate a free account

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:

RequestStatuserror
Wrong client secret401invalid_client
Scope the client may not have400invalid_scope
Public client asking for client_credentials400unauthorized_client
grant_type=magic400unsupported_grant_type
Used, expired or unknown code or refresh token400invalid_grant
response_type=token (implicit)400unsupported_response_type
Revoked or unknown token at the resource server401invalid_token, also in WWW-Authenticate
Client credentials token at /userinfo403insufficient_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:

Terminal
$ 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}
This example uses endpoints for signed-in members.Sign inCreate a free account

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.

Practise and go further