OAuth PKCE Flow
Introduction
Buddo uses OAuth 2.0 Authorization Code with PKCE (Proof Key for Code Exchange, RFC 7636) for all operator applications that need to act on behalf of a user.
PKCE was designed for public clients — mobile apps, single-page applications, CLI tools, and any environment where a client secret cannot be stored securely. Instead of relying on a shared secret, PKCE uses a one-time cryptographic challenge that proves the same application that started the authorization flow is the one finishing it.
S256 challenge method.
Plain code challenges are not supported.
client_id and registered redirect URIs. App registration is
done through the operator dashboard or the admin API.
You can verify your app exists by querying
GET /api/oauth/apps/{client_id}.
Flow Overview
The complete PKCE authorization flow involves six steps between three parties:
Your App Buddo API User
| | |
| 1. Generate code_verifier | |
| + code_challenge (S256) | |
| | |
| 2. Redirect user ──────────────>| |
| GET /api/oauth/authorize | |
| ?client_id=... | 3. Authenticate ────────>|
| &code_challenge=... | User logs in & grants |
| &code_challenge_method=S256 | consent |
| &redirect_uri=... | |
| &scope=... | 4. Redirect back |
| &state=... |<────────────────────────── |
| | |
| 4. Receive callback | |
| ?code=AUTH_CODE&state=... | |
| | |
| 5. Exchange code ──────────────>| |
| POST /api/oauth/token | |
| { code, code_verifier, | |
| client_id, redirect_uri } | |
| | |
| <── access_token + refresh ─── | |
| | |
| 6. Call API with Bearer token | |
| Authorization: Bearer ... | |
| | |
| 7. Refresh when expired | |
| POST /api/oauth/token | |
| { grant_type: refresh_token }| |
| | |
Step 1: Generate PKCE Challenge
Before redirecting the user, generate a random code verifier (43–128 characters, URL-safe) and derive a code challenge from it using SHA-256.
Bash / OpenSSL
# Generate a random 32-byte code verifier (URL-safe base64, 43 chars)
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
# Derive the S256 code challenge
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" \
| openssl dgst -sha256 -binary \
| openssl enc -base64 \
| tr -d '=' | tr '/+' '_-' | tr -d '\n')
echo "code_verifier: $CODE_VERIFIER"
echo "code_challenge: $CODE_CHALLENGE"
JavaScript
async function generatePKCE() {
// Generate 32 random bytes for the verifier
const buffer = crypto.getRandomValues(new Uint8Array(32));
const verifier = btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
// SHA-256 hash the verifier to create the challenge
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(verifier)
);
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
return { verifier, challenge };
}
const { verifier, challenge } = await generatePKCE();
code_verifier in memory (or
session storage for SPAs). You will need it in Step 4. Never send it to the
authorization endpoint — only the challenge goes there.
Step 2: Redirect to Authorization
Redirect the user's browser to the Buddo authorization endpoint with your PKCE challenge and app details.
Endpoint
GET
/api/oauth/authorize
Query Parameters
| Parameter | Required | Description |
|---|---|---|
client_id |
Yes | Your app's client ID |
response_type |
Yes | Must be code |
redirect_uri |
No | Must match one of your app's registered redirect URIs. If omitted, defaults to the first registered URI. |
code_challenge |
Yes | BASE64URL(SHA256(code_verifier)) from Step 1 |
code_challenge_method |
Yes | Must be S256 |
scope |
No | Space-separated list of requested scopes. Must be a subset of the app's allowed_scopes. See Scopes Reference. |
state |
No | Opaque value for CSRF protection. Returned unchanged in the callback. Strongly recommended. |
Example URL
https://api.buddo.xyz/api/oauth/authorize
?client_id=YOUR_CLIENT_ID
&response_type=code
&redirect_uri=https://yourapp.com/callback
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&scope=profile:read points:read
&state=xyzABC123
401 Unauthorized.
Error Responses
| Status | Cause |
|---|---|
400 | Invalid response_type, unknown client_id, unregistered redirect_uri, invalid scopes, or code_challenge_method is not S256 |
401 | Missing or invalid JWT |
403 | App is not approved |
422 | Validation errors |
Step 3: Handle the Callback
After the user authenticates and grants consent, Buddo redirects back to your
redirect_uri with an authorization code and your state
value.
Callback URL
https://yourapp.com/callback?code=AUTH_CODE_HERE&state=xyzABC123
What to do
- Validate the
stateparameter — confirm it matches the value you sent in Step 2. If it does not match, abort the flow. This prevents CSRF attacks. - Extract the
codefrom the query string. - Proceed to Step 4 immediately — authorization codes expire after 600 seconds (10 minutes) and can only be used once.
Example (JavaScript)
// Parse the callback URL
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const returnedState = params.get('state');
// Validate state matches what we sent
if (returnedState !== savedState) {
throw new Error('State mismatch — possible CSRF attack');
}
// Proceed to token exchange with the code...
Step 4: Exchange Code for Tokens
Exchange the authorization code and your original code_verifier for
an access token, refresh token, and session token.
Endpoint
POST
/api/oauth/token
Request Body (JSON)
| Field | Required | Description |
|---|---|---|
grant_type |
Yes | authorization_code |
code |
Yes | The authorization code from the callback |
redirect_uri |
Yes | Must match the redirect_uri used in Step 2 |
client_id |
Yes | Your app's client ID |
code_verifier |
Yes* | The original PKCE verifier from Step 1. Required for public clients (alternative: client_secret for confidential clients). |
cURL Example
curl -X POST https://api.buddo.xyz/api/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "authorization_code",
"code": "AUTH_CODE_HERE",
"redirect_uri": "https://yourapp.com/callback",
"client_id": "YOUR_CLIENT_ID",
"code_verifier": "YOUR_CODE_VERIFIER"
}'
Success Response 200 OK
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJlZnJl...",
"session_token": "c2Vzc2lvbi10b2tlbi0x...",
"session_token_expires_at": "2026-05-21T14:30:00Z"
}
| Field | Type | Description |
|---|---|---|
access_token |
string | Bearer token for API requests |
token_type |
string | Always Bearer |
expires_in |
integer | Token lifetime in seconds |
refresh_token |
string | Use to obtain a new access token when the current one expires |
session_token |
string | Short-lived session token (only present on authorization_code grants) |
session_token_expires_at |
datetime | ISO 8601 expiry of the session token |
Error Responses
Token exchange errors follow the OAuth 2.0 error format:
{
"error": "invalid_grant",
"error_description": "Authorization code has expired"
}
| Error Code | Cause |
|---|---|
invalid_client | Unknown client_id |
invalid_grant | Code expired, already used, redirect_uri mismatch, or invalid code_verifier |
invalid_request | PKCE verifier required but not provided |
Step 5: Use the Access Token
Include the access token in the Authorization header on every API
request.
curl https://api.buddo.xyz/api/external/user \
-H "Authorization: Bearer ACCESS_TOKEN_HERE"
Example: Get User Profile
curl https://api.buddo.xyz/api/external/user \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
Example: Read Point Balance
curl https://api.buddo.xyz/api/external/points \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
Example: Spend Points
curl -X POST https://api.buddo.xyz/api/external/points/spend \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
-H "Content-Type: application/json" \
-d '{"amount": 100, "description": "Game entry fee"}'
401 Unauthorized. See the Scopes Reference
below.
Step 6: Refresh Tokens
When the access token expires, use the refresh token to obtain a new one without requiring the user to re-authenticate.
Endpoint
POST
/api/oauth/token
Request Body (JSON)
| Field | Required | Description |
|---|---|---|
grant_type |
Yes | refresh_token |
refresh_token |
Yes | The refresh token from the original token exchange |
client_id |
Yes | Your app's client ID |
client_secret |
Yes | Your app's client secret |
cURL Example
curl -X POST https://api.buddo.xyz/api/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "refresh_token",
"refresh_token": "dGhpcyBpcyBhIHJlZnJl...",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET"
}'
The response is the same token response format as Step 4,
with a new access_token and refresh_token. Note that
refresh grants do not include session_token.
Token Introspection
You can verify whether an access token is still active and inspect its metadata using the introspection endpoint. No authentication is required for this call.
Endpoint
POST
/api/oauth/token/verify
Request Body (JSON)
{
"token": "eyJhbGciOiJIUzI1NiIs..."
}
Response
{
"active": true,
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"app_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"scopes": ["profile:read", "points:read"],
"expires_at": "2026-05-21T15:30:00Z"
}
If the token is expired or invalid, the response still returns 200 OK
with "active": false and a reason field (e.g.,
"expired").
Scopes Reference
Request scopes in the scope parameter of Step 2. Only scopes that
are a subset of your app's allowed_scopes can be requested.
| Scope | Description | Used By |
|---|---|---|
profile:read |
Read user profile and session information | GET /api/external/user, GET /api/external/session/status, POST /api/external/session/end, GET /api/external/ads/serve, POST /api/external/ads/event |
points:read |
Read user point balance | GET /api/external/points |
points:spend |
Spend user points (credits the operator account) | POST /api/external/points/spend |
points:transfer |
Transfer points between users | POST /api/external/points/transfer |
deploy:manage |
Deploy and manage hosted applications | /api/deploy/* endpoints |
app:balance:read |
Read the operator app's earned-point balance | GET /api/external/app/balance, /api/operator/* endpoints |
points:award |
Deprecated. Award is admin-only. Retained for existing token compatibility. | N/A (admin only) |
Need scopes that are not yet in your app's allowed_scopes? Submit a
request via POST /api/oauth/my-apps/{id}/scope-request — it
requires admin approval.
Error Handling
Standard API Errors
Most API endpoints return errors in this format:
{
"error": "Description of what went wrong"
}
OAuth-Specific Errors
The /api/oauth/token endpoint uses
RFC 6749
error format with error and error_description fields:
{
"error": "invalid_grant",
"error_description": "Code verifier does not match the code challenge"
}
Common Error Scenarios
| Scenario | Status | Error Code | Fix |
|---|---|---|---|
| Unknown client_id | 400 | invalid_client |
Check your client_id. Verify your app exists with GET /api/oauth/apps/{client_id}. |
| Authorization code expired | 400 | invalid_grant |
Codes expire after 600 seconds (10 min). Restart the flow. |
| Code already used | 400 | invalid_grant |
Authorization codes are single-use. Restart the flow. |
| PKCE verifier mismatch | 400 | invalid_grant |
Ensure you are sending the original code_verifier, not the challenge. |
| redirect_uri mismatch | 400 | invalid_grant |
The redirect_uri in the token request must match the one used in the authorize request exactly. |
| Missing PKCE verifier | 400 | invalid_request |
Public clients must include code_verifier. Confidential clients can use client_secret instead. |
| Insufficient scopes | 401 | — | The access token lacks the scope required by the endpoint. Re-authorize with the correct scopes. |
| App not approved | 403 | — | Your app must be approved by an admin before users can authorize it. |
Security Best Practices
-
HTTPS only. All Buddo API traffic must use HTTPS. The production
base URL is
https://api.buddo.xyz. Never send tokens over plain HTTP. -
Always use the
stateparameter. Generate a cryptographically random value, store it before redirecting, and verify it in the callback. This prevents CSRF attacks. - Never log tokens. Access tokens, refresh tokens, and session tokens are sensitive credentials. Do not write them to application logs, analytics, or error reporting systems.
-
Store tokens securely. In browser apps, use
sessionStorageor in-memory variables — neverlocalStoragefor access tokens. In server apps, use encrypted storage. - Rotate refresh tokens. Each refresh response includes a new refresh token. Always replace the stored token with the latest one. The previous token may be invalidated.
- Use minimal scopes. Only request the scopes your application actually needs. Users are more likely to grant consent for focused permissions.
-
Validate the
stateon every callback. Even if you "know" the request is legitimate, always check. Skipping validation opens a CSRF vector. -
Handle token expiry gracefully. Check
expires_inand refresh proactively before the token expires, rather than waiting for a401response. -
Never embed tokens in URLs. Pass tokens in the
Authorizationheader, not in query strings. URL parameters are logged by servers, proxies, and browser history. -
Revoke tokens when disconnecting. Users can revoke your app's
access via
DELETE /api/user/connected-apps/{app_id}. Handle401responses gracefully and prompt re-authorization when needed.
Further Reading
- OAuth API Reference — Full endpoint documentation
- JWT vs OAuth Tokens — When to use each authentication method
- Quickstart — Register and make your first API call
- RFC 7636 — PKCE specification
- RFC 6749 — OAuth 2.0 specification