OAuth 2.0 JSON Token Response: PKCE, Introspection & Grant Types
Last updated:
OAuth 2.0 token endpoints return a JSON object with access_token, token_type (always "Bearer"), expires_in (seconds), and optionally refresh_token and scope — this is the application/json response you parse after exchanging an authorization code. The token response is defined in RFC 6749 §5.1; common values: expires_in: 3600 (1 hour for access tokens), token_type: "Bearer", and scope: "read write" (space-separated list). PKCE (RFC 7636) adds code_verifier and code_challenge to the authorization code flow — required for public clients (SPAs, mobile apps) since client secrets cannot be stored securely.
This guide covers the authorization code + PKCE flow JSON exchanges, client credentials grant, token introspection (RFC 7662), token revocation (RFC 7009), OpenID Connect id_token JWT payload, and common JSON error responses (error, error_description). For the underlying JWT security model that backs most access tokens, see our dedicated JWT guide.
Token Response JSON: access_token, token_type, expires_in
The token endpoint returns Content-Type: application/json and Cache-Control: no-store on every successful response. RFC 6749 §5.1 defines five fields — two required, three optional. The expires_in value is a duration in seconds from issuance, not a Unix timestamp; always convert it to an absolute expiry time before storing.
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI2In0.eyJzdWIiOiJ1c2VyXzEyMyJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "8xLOxBtZp8",
"scope": "read write"
}
// ── TypeScript: parse and store expiry as absolute time ──
interface OAuthTokenResponse {
access_token: string
token_type: 'Bearer'
expires_in: number
refresh_token?: string
scope?: string
id_token?: string // OpenID Connect only
}
async function fetchToken(code: string): Promise<OAuthTokenResponse> {
const res = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: 'https://myapp.com/callback',
client_id: 'my-client-id',
client_secret: process.env.CLIENT_SECRET!,
}),
})
// Always check for error field BEFORE treating as success
const json = await res.json()
if (json.error) throw new Error(`OAuth error: ${json.error} — ${json.error_description ?? ''}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return json as OAuthTokenResponse
}
// Store expiry as absolute Unix timestamp (seconds)
const token = await fetchToken(authCode)
const issuedAt = Math.floor(Date.now() / 1000)
const expiresAt = issuedAt + token.expires_in // 1716115200, NOT 3600
// expires_in is a DURATION — never store it raw as an expiry timestampThe token_type field is always "Bearer" in modern OAuth — it tells you to use the Authorization: Bearer <access_token> header when calling APIs. Never send Bearer tokens in URL query parameters, as they appear in server access logs and browser history. The scope field in the response may differ from the requested scope if the authorization server granted a subset — always check the returned scope rather than assuming all requested scopes were granted. For robust JSON API design, validate the scope field before making downstream calls.
Authorization Code + PKCE Flow
PKCE (Proof Key for Code Exchange, RFC 7636) binds the authorization code to the client that requested it, preventing interception. It is mandatory for public clients (SPAs, native apps) and recommended for confidential clients too. The critical insight: PKCE parameters are URL query parameters and POST body fields — they never appear in the JSON token response.
// ── Step 1: Generate code_verifier (43–128 random URL-safe chars) ──
const array = new Uint8Array(32)
crypto.getRandomValues(array)
const codeVerifier = btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
// Store in sessionStorage (NOT localStorage — cleared on tab close)
sessionStorage.setItem('pkce_verifier', codeVerifier)
// ── Step 2: Derive code_challenge = BASE64URL(SHA256(verifier)) ──
const encoder = new TextEncoder()
const data = encoder.encode(codeVerifier)
const digest = await crypto.subtle.digest('SHA-256', data)
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
// ── Step 3: Authorization request (URL params — not JSON) ──────
const authUrl = new URL('https://auth.example.com/authorize')
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('client_id', 'my-client-id')
authUrl.searchParams.set('redirect_uri', 'https://myapp.com/callback')
authUrl.searchParams.set('scope', 'openid email read write')
authUrl.searchParams.set('state', crypto.randomUUID()) // CSRF protection
authUrl.searchParams.set('code_challenge', codeChallenge)
authUrl.searchParams.set('code_challenge_method', 'S256') // always S256, never plain
window.location.assign(authUrl.toString())
// ── Step 4: Code exchange — send verifier, receive JSON token ──
const codeVerifier = sessionStorage.getItem('pkce_verifier')!
const tokenRes = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: new URLSearchParams(window.location.search).get('code')!,
redirect_uri: 'https://myapp.com/callback',
client_id: 'my-client-id',
code_verifier: codeVerifier, // verifier goes here — not code_challenge
}),
})
// JSON response is identical whether or not PKCE was used:
// { "access_token": "...", "token_type": "Bearer", "expires_in": 3600 }Always use code_challenge_method=S256 (SHA-256) — the plain method sends the verifier itself as the challenge, providing no security benefit. Store the code_verifier in sessionStorage rather than localStorage so it is automatically cleared when the tab closes. Include a random state parameter in the authorization request and verify it matches when the code returns to your redirect URI — this prevents CSRF attacks. See the JSON security guide for additional token storage guidance.
Client Credentials Grant
The client credentials grant (RFC 6749 §4.4) is the correct flow for machine-to-machine (M2M) authentication — backend services, CLI tools, CI/CD pipelines, and background workers that act on their own behalf with no human user involved. The client authenticates directly with its client_id and client_secret; no user consent step, no authorization code, no refresh token.
// ── Client credentials token request ─────────────────────────────
POST /oauth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)
grant_type=client_credentials&scope=api%3Aread%20api%3Awrite
// ── Successful JSON response ───────────────────────────────────────
{
"access_token": "eyJhbGciOiJSUzI1NiJ9...",
"token_type": "Bearer",
"expires_in": 86400,
"scope": "api:read api:write"
}
// No refresh_token — client can re-authenticate at any time
// ── Node.js implementation with token caching ────────────────────
let cachedToken: { value: string; expiresAt: number } | null = null
async function getClientCredentialsToken(): Promise<string> {
const now = Math.floor(Date.now() / 1000)
// Reuse cached token if it has > 60 seconds remaining
if (cachedToken && cachedToken.expiresAt - now > 60) {
return cachedToken.value
}
const res = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + btoa(`${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`),
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'api:read api:write',
}),
})
const json = await res.json()
if (json.error) throw new Error(`OAuth error: ${json.error}`)
cachedToken = {
value: json.access_token,
expiresAt: now + json.expires_in,
}
return cachedToken.value
}
// Use in API calls
const token = await getClientCredentialsToken()
const apiRes = await fetch('https://api.example.com/data', {
headers: { Authorization: `Bearer ${token}` },
})Cache the client credentials access token and reuse it until it is close to expiry — fetching a new token on every API call adds unnecessary latency and load on the authorization server. A 60-second buffer before the expires_in deadline accounts for clock skew and network latency. Prefer the Authorization: Basic header for client authentication over sending credentials in the POST body, as the header method is more widely supported and separates credentials from the grant parameters. For JSON webhooks, client credentials tokens are the standard authentication mechanism for webhook delivery verification.
Refresh Token Exchange
Refresh tokens allow obtaining new access tokens after expiry without requiring the user to re-authenticate. The refresh grant uses the same token endpoint with grant_type=refresh_token. The JSON response has the same shape as the original authorization code response.
// ── Refresh token request ─────────────────────────────────────────
POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=8xLOxBtZp8
&client_id=my-client-id
&client_secret=my-client-secret // confidential clients only
// ── Successful JSON response (same shape as authorization code) ──
{
"access_token": "eyJhbGciOiJSUzI1NiJ9.NEW_TOKEN...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "9yMPbcUuQr", // new refresh token if rotation enabled
"scope": "read write"
}
// ── Expired or invalid refresh token ─────────────────────────────
HTTP/1.1 400 Bad Request
{
"error": "invalid_grant",
"error_description": "The refresh token has expired"
}
// ── TypeScript: proactive refresh with 60-second buffer ──────────
interface StoredTokens {
accessToken: string
refreshToken: string
expiresAt: number // absolute Unix seconds
}
async function refreshAccessToken(stored: StoredTokens): Promise<StoredTokens> {
const res = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: stored.refreshToken,
client_id: process.env.CLIENT_ID!,
}),
})
const json = await res.json()
if (json.error) {
// invalid_grant: refresh token expired — must re-authorize user
throw new Error(`Refresh failed: ${json.error}`)
}
const now = Math.floor(Date.now() / 1000)
return {
accessToken: json.access_token,
// Use new refresh_token if provided (rotation), else keep old one
refreshToken: json.refresh_token ?? stored.refreshToken,
expiresAt: now + json.expires_in,
}
}
function isTokenExpired(stored: StoredTokens): boolean {
return Math.floor(Date.now() / 1000) > stored.expiresAt - 60
}Refresh token rotation is a security best practice: the server issues a new refresh_token on each refresh and invalidates the previous one. If the response includes a new refresh_token, store it immediately and discard the old one before making any other request — using a rotated-out token results in invalid_grant. Some providers implement refresh token reuse detection: if they detect two requests using the same refresh token, they revoke the entire token family, forcing re-authorization. Store refresh tokens in HttpOnly, Secure, SameSite=Strict cookies or server-side session storage — never in JavaScript-accessible localStorage.
Token Introspection (RFC 7662)
Token introspection lets resource servers verify a token's validity and read its metadata without needing access to the signing keys or shared secrets. The resource server POSTs the token to the authorization server's introspection endpoint, which returns a JSON object. The endpoint requires client authentication to prevent arbitrary callers from probing token metadata.
// ── Introspection request ─────────────────────────────────────────
POST /oauth/introspect HTTP/1.1
Host: auth.example.com
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
token=eyJhbGciOiJSUzI1NiJ9...
// ── Active token response ─────────────────────────────────────────
{
"active": true,
"scope": "read write",
"client_id": "my-app",
"username": "alice",
"sub": "user_123",
"iss": "https://auth.example.com",
"exp": 1716115200, // Unix TIMESTAMP (not a duration)
"iat": 1716111600,
"token_type": "Bearer",
"jti": "a1b2c3d4-..." // JWT ID, if applicable
}
// ── Inactive / invalid token — only active: false returned ───────
{ "active": false }
// ── Node.js: introspect with caching ──────────────────────────────
const introspectionCache = new Map<string, { result: boolean; exp: number }>()
async function introspectToken(token: string): Promise<boolean> {
const cached = introspectionCache.get(token)
const now = Math.floor(Date.now() / 1000)
if (cached && now < cached.exp) return cached.result
const res = await fetch('https://auth.example.com/oauth/introspect', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + btoa(`${CLIENT_ID}:${CLIENT_SECRET}`),
},
body: new URLSearchParams({ token }),
})
const json = await res.json()
// Cache result for 30s (or until exp, whichever is sooner)
const cacheUntil = json.active ? Math.min(json.exp, now + 30) : now + 30
introspectionCache.set(token, { result: json.active, exp: cacheUntil })
return json.active === true
}The active field is the definitive check — always read it first. An inactive response contains only {"active": false}; do not attempt to access scope or sub from an inactive response. The exp in the introspection response is a Unix timestamp (a point in time), unlike expires_in in the token response (a duration in seconds) — this asymmetry is a common source of bugs. Cache introspection results for up to 60 seconds, but no longer than the token's exp, to balance performance against security (a revoked token should not be trusted after the cache window).
OpenID Connect: id_token JWT in the Token Response
OpenID Connect (OIDC) extends OAuth 2.0 by adding an id_token field to the token response when the openid scope is requested. The id_tokenis a JWT whose base64url-decoded payload contains JSON claims about the authenticated user. You must verify the JWT signature using the provider's JWKS endpoint before trusting any claim.
// ── OIDC token response (openid scope requested) ──────────────────
{
"access_token": "eyJhbGciOiJSUzI1NiJ9.ACCESS...",
"token_type": "Bearer",
"expires_in": 3600,
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0xIn0.CLAIMS.SIGNATURE"
}
// ── Decoded id_token JWT payload (base64url-decode the middle part) ─
{
"iss": "https://auth.example.com", // issuer — must match provider
"sub": "user_abc123", // stable user identifier
"aud": "my-client-id", // must match your client_id
"exp": 1716115200, // expiry (Unix seconds)
"iat": 1716111600, // issued-at (Unix seconds)
"nonce": "n-0S6_WzA2Mj", // verify against auth request nonce
"email": "alice@example.com", // with email scope
"email_verified": true,
"name": "Alice Smith", // with profile scope
"given_name": "Alice",
"family_name": "Smith",
"picture": "https://example.com/alice.jpg"
}
// ── Verify with jose library (verifies sig + exp + iss + aud) ────
import { createRemoteJWKSet, jwtVerify } from 'jose'
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json')
)
async function verifyIdToken(idToken: string, expectedNonce: string) {
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: 'https://auth.example.com',
audience: 'my-client-id',
})
// Manually verify nonce to prevent replay attacks
if (payload.nonce !== expectedNonce) {
throw new Error('Nonce mismatch — possible replay attack')
}
// Use sub (not email) as stable user identifier in your database
return payload
}Use sub — not email — as the primary user identifier in your database. Email addresses can change; sub is stable and unique per issuer. Verify the nonce claim against the nonce you sent in the authorization request — this prevents replay attacks where an attacker reuses a captured id_token. The id_token is for client-side identity only — do not send it to your resource server API as an access token; use the access_token for API calls instead. For deeper coverage of JWT structure and algorithms, see our JWT security guide.
Error Responses: error and error_description
OAuth 2.0 error responses follow a consistent JSON format defined in RFC 6749 §5.2. The token endpoint returns HTTP 400 for most errors and 401 for client authentication failures. Always check for an error field before processing the response as a success — a successful JSON parse does not mean a successful OAuth response.
// ── Standard error response format ───────────────────────────────
{
"error": "invalid_grant",
"error_description": "The authorization code has expired or already been used",
"error_uri": "https://auth.example.com/docs/errors#invalid_grant"
}
// ── Common OAuth error codes ───────────────────────────────────────
// invalid_grant (HTTP 400) — most common:
// - Authorization code already used or expired
// - Refresh token expired, revoked, or rotated-out
// - code_verifier does not match stored code_challenge (PKCE failure)
// → Remedy: redirect user through full authorization flow
// invalid_client (HTTP 401):
// - Wrong client_id, client_secret, or missing credentials
// → Remedy: fix environment variables, check client registration
// access_denied (HTTP 400):
// - User clicked "Deny" on the consent screen
// → Remedy: show friendly message, offer retry
// invalid_scope (HTTP 400):
// - Requested scope not registered or not permitted for this client
// → Remedy: reduce scope, check authorization server client settings
// unsupported_grant_type (HTTP 400):
// - Grant type not enabled for this client
// → Remedy: check client configuration in authorization server
// ── TypeScript: exhaustive error handling ─────────────────────────
type OAuthErrorCode =
| 'invalid_grant'
| 'invalid_client'
| 'access_denied'
| 'invalid_scope'
| 'unsupported_grant_type'
| 'server_error'
interface OAuthErrorResponse {
error: OAuthErrorCode
error_description?: string
error_uri?: string
}
async function callTokenEndpoint(body: URLSearchParams): Promise<OAuthTokenResponse> {
const res = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const json = await res.json()
// Check error field FIRST — do not trust res.ok alone
if ('error' in json) {
const err = json as OAuthErrorResponse
// Log error_description for developers, never expose to end users
console.error(`OAuth ${err.error}: ${err.error_description}`)
if (err.error === 'invalid_grant') {
throw new Error('SESSION_EXPIRED') // translate to app-level error
}
throw new Error(`AUTH_ERROR:${err.error}`)
}
return json as OAuthTokenResponse
}Translate error codes into user-facing messages appropriate for your application — never display the raw error_description to end users, as it may contain technical details or server internals. The error_uri field is optional and points to documentation; parse it only for developer tooling or logging. For patterns on how to structure these errors in your own APIs, see the JSON API design and JSON API error handling guides. When debugging OAuth errors in production, always log the full error and error_description server-side before masking them for the user.
Key Terms
- access_token
- A credential string returned in the OAuth 2.0 JSON token response that a client presents to a resource server to prove authorization. Typically a JWT (self-contained, verifiable without a network call) or an opaque string (requires token introspection to validate). Sent in the
Authorization: Bearer <value>HTTP header. Short-lived by design — typically 15 minutes to 1 hour — and scoped to specific resources or operations declared in thescopefield. Never store access tokens inlocalStorageor as non-HttpOnlycookies; they are sensitive credentials. - Bearer token
- A token type defined in RFC 6750 where any party holding the token string can use it to gain access — there is no additional proof of identity required beyond possession. The
token_type: "Bearer"field in the OAuth JSON response signals this scheme. Bearer tokens must be transmitted exclusively over HTTPS to prevent interception. The scheme name comes from the analogy to physical bearer instruments (cash, bearer bonds) where possession equals ownership. In API requests, format as:Authorization: Bearer eyJhbGci.... - PKCE
- Proof Key for Code Exchange (RFC 7636) — a security extension for the OAuth 2.0 authorization code flow. The client generates a random
code_verifier(43–128 URL-safe characters), computescode_challenge = BASE64URL(SHA256(code_verifier)), and sends the challenge in the authorization request URL. During the token exchange, the client sends the originalcode_verifierin the POST body; the server verifies the hash matches. This prevents authorization code interception attacks because an attacker who intercepts the code cannot compute the original verifier from the challenge alone. - token introspection
- An OAuth 2.0 extension (RFC 7662) providing an endpoint where resource servers can POST a token to check its current validity and read its metadata. The authorization server returns a JSON object with an
activeboolean field. Whenactive: true, additional metadata is returned:scope,sub,client_id,exp(Unix timestamp), andtoken_type. Whenactive: false, only that field is returned. Introspection is the correct approach for validating opaque (non-JWT) tokens that cannot be self-validated. - refresh token
- A long-lived credential returned as
refresh_tokenin the OAuth 2.0 JSON token response, used to obtain new access tokens after the current one expires — without requiring the user to re-authenticate. Refresh tokens are opaque strings (not JWTs) bound to a specific user, client, and scope. They must be stored securely (server-side session,HttpOnlycookie) and can be revoked by the authorization server. Many providers implement refresh token rotation: each use returns a new refresh token and invalidates the previous one. - id_token
- An OpenID Connect (OIDC) extension to the OAuth 2.0 token response — a JWT returned in the
id_tokenfield when theopenidscope is requested. The JWT payload contains identity claims about the authenticated user: required claims aresub(subject identifier),iss(issuer),aud(audience — yourclient_id),exp(expiry), andiat(issued-at). Theid_tokenmust be signature-verified using the provider's JWKS before trusting its claims. Use theaccess_token— not theid_token— for API calls.
FAQ
What does an OAuth 2.0 token response look like in JSON?
An OAuth 2.0 token response is a JSON object with Content-Type: application/json and Cache-Control: no-store. Required fields: access_token (the credential string) and token_type (always "Bearer"). Recommended: expires_in (integer seconds — a duration, not a timestamp). Optional: refresh_token (long-lived token for renewals) and scope (space-separated list of granted scopes). OpenID Connect adds id_token (a signed JWT). Full example: {"access_token":"eyJhbGci...","token_type":"Bearer","expires_in":3600,"refresh_token":"8xLOxBtZp8","scope":"read write"}. Always check for an error field before treating the response as successful.
What is PKCE in OAuth 2.0?
PKCE (Proof Key for Code Exchange, RFC 7636) is a security extension for the OAuth 2.0 authorization code flow. The client generates a random code_verifier, computes code_challenge = BASE64URL(SHA256(code_verifier)), and sends the challenge in the authorization request. During code exchange, the original code_verifier is sent in the POST body — the server verifies the hash matches. This prevents authorization code interception because an attacker who steals the code cannot compute the verifier from the challenge. PKCE is required for public clients (SPAs, native apps) and recommended for all clients regardless of whether they use a client secret.
How do I use a Bearer token in an API request?
Send the access_token value from the OAuth JSON response in the Authorization HTTP header with the Bearer scheme: Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.... In JavaScript: fetch(url, { headers: { "Authorization": "Bearer " + tokenResponse.access_token } }). Never send tokens in URL query parameters — they appear in server logs, browser history, and HTTP referer headers. Always use HTTPS. The token_type field in the OAuth response tells you the scheme — it is always "Bearer" for modern OAuth 2.0.
How do I refresh an OAuth 2.0 access token?
POST to the token endpoint with grant_type=refresh_token, refresh_token=<value>, and your client_id (plus client_secret for confidential clients) as application/x-www-form-urlencoded. The server returns a new JSON token response with a fresh access_token and expires_in. If the response includes a new refresh_token (rotation), store it immediately and discard the old one. If refresh fails with invalid_grant, the refresh token has expired and the user must complete the full authorization flow again. Proactively refresh 60 seconds before expires_in to avoid request failures due to clock skew.
What is token introspection in OAuth 2.0?
Token introspection (RFC 7662) is an authorization server endpoint that lets resource servers verify a token's validity without holding signing keys. POST token=<value> to the /introspect endpoint with client authentication. The JSON response always includes active (boolean). If true, it also includes scope, sub, client_id, exp (Unix timestamp — not a duration), and token_type. If false, only {"active": false} is returned. Cache responses for 30–60 seconds to reduce authorization server load. Use introspection for opaque tokens; use JWKS verification for JWT tokens.
What is the difference between OAuth 2.0 and OpenID Connect?
OAuth 2.0 (RFC 6749) is an authorization framework — it answers "what can this client do?" by issuing access tokens that grant permission to resources, but it does not tell you who the user is. OpenID Connect (OIDC) is an identity layer built on top of OAuth 2.0 — it adds an id_token JWT to the token response that answers "who is this user?" with claims like sub, email, and name. OIDC also defines a UserInfo endpoint, a discovery document at /.well-known/openid-configuration, and standardized claim names. Use OAuth 2.0 alone for API authorization between services; use OIDC when you need to authenticate users and know their identity.
How do I handle OAuth 2.0 errors in JSON?
Always check for an error field in the parsed JSON before treating the response as a success — a successful HTTP parse does not mean a successful OAuth flow. Pattern: if (json.error) throw new Error(json.error). Common error codes: invalid_grant (expired or already-used code or refresh token — re-authorize the user), invalid_client (wrong credentials — check environment variables), access_denied (user denied consent), invalid_scope (reduce requested scopes), unsupported_grant_type (check client configuration). Log error_description server-side for debugging but never show it directly to end users — translate error codes into user-friendly messages instead.
What is the client credentials grant?
The client credentials grant (RFC 6749 §4.4) is the OAuth 2.0 flow for machine-to-machine authentication — no human user is involved. A backend service authenticates with its client_id and client_secret, receives an access_token directly, and no refresh_token is issued (the service can re-authenticate at any time). POST to the token endpoint with grant_type=client_credentials and optionally scope. Cache the resulting access token and reuse it until close to expiry — fetching a new token on every API call adds unnecessary latency. Use this flow for CI/CD pipelines, background workers, scheduled jobs, and service-to-service calls.
Further reading and primary sources
- RFC 6749 — The OAuth 2.0 Authorization Framework — Foundational OAuth 2.0 specification defining token response format, grant types, and error responses
- RFC 7636 — PKCE for OAuth Public Clients — Proof Key for Code Exchange specification — code_verifier, code_challenge, and S256 method
- RFC 7662 — OAuth 2.0 Token Introspection — Token introspection endpoint specification and active/inactive JSON response format
- RFC 7009 — OAuth 2.0 Token Revocation — Token revocation endpoint specification for access tokens and refresh tokens
- OpenID Connect Core 1.0 — OIDC specification defining id_token JWT claims, UserInfo endpoint, and discovery