This cheatsheet is a lookup for OAuth 2.0 grant types and the exact parameters each request carries. It is for developers who must pick a flow for a web app, mobile app, server job or TV app, and then write the requests by hand. The confusion it clears up is that "OAuth flow" means a grant type, and only a few of the original four are still recommended. For the concepts behind it, see the OAuth 2.0 glossary page.
| Situation | Grant type | `grant_type` value | Status |
|---|---|---|---|
| Server-side, SPA or mobile app with a user | Authorization code, with PKCE | `authorization_code` | Recommended |
| Service calling an API as itself | Client credentials | `client_credentials` | Fine for confidential clients |
| Renewing an expired access token | Refresh token | `refresh_token` | Fine, rotate for public clients |
| TV, console or CLI with no browser | Device authorization (RFC 8628) | `urn:ietf:params:oauth:grant-type:device_code` | Fine |
| Exchange one token for another | Token exchange (RFC 8693) | `urn:ietf:params:oauth:grant-type:token-exchange` | Fine |
| Assertion signed by a trusted issuer | JWT bearer (RFC 7523) | `urn:ietf:params:oauth:grant-type:jwt-bearer` | Fine |
| Browser gets the token directly | Implicit | none, `response_type=token` | Should not be used (RFC 9700) |
| App collects the user's password | Resource owner password | `password` | Must not be used (RFC 9700) |
RFC 6749 from 2012 defined authorization code, implicit, password and client credentials. RFC 9700 from January 2025 is the current security guidance.
| Parameter | Required | Value |
|---|---|---|
| `response_type` | Yes | `code` |
| `client_id` | Yes | Your registered client ID |
| `redirect_uri` | Depends | Must match a registered URI exactly |
| `scope` | No | Space-delimited, so `read write` |
| `state` | Recommended | Random value you compare on the callback |
| `code_challenge` | With PKCE | Base64url of SHA-256 of the verifier |
| `code_challenge_method` | With PKCE | `S256` |
| Grant | Parameters |
|---|---|
| `authorization_code` | `code`, `redirect_uri`, `client_id`, `code_verifier` |
| `client_credentials` | `scope` (optional) |
| `refresh_token` | `refresh_token`, `scope` (optional, can only narrow) |
| device code | `device_code`, `client_id` |
The body is application/x-www-form-urlencoded, not JSON. A confidential client authenticates with HTTP Basic or by sending client_id and client_secret in the body.
| Field | Required | Notes |
|---|---|---|
| `access_token` | Yes | Opaque string or a JWT |
| `token_type` | Yes | Usually `Bearer`, compared case-insensitively |
| `expires_in` | Recommended | Lifetime in seconds, for example `3600` |
| `refresh_token` | No | Not returned for client credentials |
| `scope` | If different | Required when it differs from the request |
| Where | `error` | Meaning |
|---|---|---|
| Token endpoint | `invalid_request` | Missing or repeated parameter, malformed request |
| Token endpoint | `invalid_client` | Client authentication failed |
| Token endpoint | `invalid_grant` | Code or refresh token is invalid, expired, revoked, issued to another client, or the `redirect_uri` differs |
| Token endpoint | `unauthorized_client` | Client may not use this grant type |
| Token endpoint | `unsupported_grant_type` | Server does not support the grant |
| Token endpoint | `invalid_scope` | Scope is unknown or exceeds the grant |
| Authorization endpoint | `access_denied` | User or server refused |
| Authorization endpoint | `unsupported_response_type` | Server cannot issue that response type |
| Authorization endpoint | `server_error`, `temporarily_unavailable` | Server-side failure, retry later |
| Device flow | `authorization_pending` | User has not finished yet, keep polling |
| Device flow | `slow_down` | Add 5 seconds to the polling interval |
| Device flow | `expired_token` | The device code expired, start over |
| Resource server | `invalid_token` | Sent with HTTP 401 (RFC 6750) |
| Resource server | `insufficient_scope` | Sent with HTTP 403 (RFC 6750) |
import urllib.parse as u
print(u.urlencode({'response_type': 'code', 'client_id': 's6BhdRkqt3',
'redirect_uri': 'https://app.example.com/cb', 'scope': 'read write',
'state': 'af0ifjsldkj',
'code_challenge': 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
'code_challenge_method': 'S256'}))
response_type=code&client_id=s6BhdRkqt3&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb&scope=read+write&state=af0ifjsldkj&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256
Append this after ? on the authorization endpoint. Use urlencode rather than string concatenation so the redirect URI is percent-encoded.
import base64, hashlib, secrets
verifier = secrets.token_urlsafe(32)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode('ascii')).digest()).rstrip(b'=').decode()
print(len(verifier), len(challenge))
rfc = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
print(base64.urlsafe_b64encode(hashlib.sha256(rfc.encode()).digest()).rstrip(b'=').decode())
43 43
E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
The verifier must be 43 to 128 characters from the unreserved set (RFC 7636). The second line is the RFC 7636 test vector. Keep the verifier in the session and send it only to the token endpoint.
import urllib.parse as u
cb = 'https://app.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj'
print(u.parse_qs(u.urlparse(cb).query))
{'code': ['SplxlOBeZQQYbYS6WxSbIA'], 'state': ['af0ifjsldkj']}
Compare state to the value you stored before redirecting, and reject the request on any mismatch.
curl -u 's6BhdRkqt3:7Fjfp0ZBr1KtDRbnfVdmIw' -d grant_type=client_credentials -d scope=read https://auth.example.com/token
This request was sent to a local listener, which captured:
POST /token HTTP/1.1
Host: 127.0.0.1:8765
Authorization: Basic czZCaGRSa3F0Mzo3RmpmcDBaQnIxS3REUmJuZlZkbUl3
User-Agent: curl/8.5.0
Accept: */*
Content-Length: 40
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=read
The Basic value is base64 of client_id:client_secret. RFC 6749 says a refresh token should not be included in this response.
from urllib.parse import urlencode as e
print(e({'grant_type': 'refresh_token', 'refresh_token': 'tGzv3JOkF0XG5Qx2TlKWIA', 'scope': 'read'}))
print(e({'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', 'device_code': 'GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS', 'client_id': '459691054427'}))
print(e({'token': '45ghiukldjahdnhzdauz', 'token_type_hint': 'refresh_token'}))
grant_type=refresh_token&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA&scope=read
grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS&client_id=459691054427
token=45ghiukldjahdnhzdauz&token_type_hint=refresh_token
The third body goes to the revocation endpoint from RFC 7009. Grant type URNs contain colons, so they must be percent-encoded in form bodies.
interval = 5
for err in ['authorization_pending', 'slow_down', 'authorization_pending']:
if err == 'slow_down':
interval += 5
print(err, '-> wait', interval, 'seconds')
authorization_pending -> wait 5 seconds
slow_down -> wait 10 seconds
authorization_pending -> wait 10 seconds
Use 5 seconds when the server gives no interval. After slow_down, the longer interval stays for every later request.
invalid_request. Send Content-Type: application/x-www-form-urlencoded.invalid_grant.invalid_grant from a redirect mismatch: the redirect_uri in the token request must equal the one in the authorization request, character for character. A trailing slash is enough to fail.?access_token= but says it should not be used when a header is possible, because URLs end up in logs. Send Authorization: Bearer <token>.S256; plain is only for clients that cannot hash.scope as a list with commas: scopes are separated by a single space. In a query string that space appears as + or %20.redirect_uri shows as %3A%2F%2F