OAuth 2.0 — third-party clients
Driftstack ships an OAuth 2.0 Authorization Server so third-party apps can act on a customer’s behalf without ever holding the customer’s API key. The flow is the standard Authorization Code grant with PKCE required (RFC 7636 — no exceptions, even for confidential clients); access tokens are bearer-style and short-lived (one hour); no refresh tokens are issued.
Bearer API keys (
ds_live_…) and OAuth access tokens BOTH use theAuthorization: Bearer <token>header on/v1/*requests. The server differentiates by token prefix; both surfaces respect the same scope + rate-limit + audit pipeline.
When to use this
- First-party UIs (the customer’s dashboard, their own internal apps) → use API keys + the dashboard auth flows. OAuth is overkill.
- Third-party integrations (Zapier-style automation, customer agencies acting on a customer’s behalf, a SaaS product that hooks into a customer’s Driftstack account) → use OAuth so the customer can revoke your integration without rotating their API key.
If you’re a first-party customer integrating into your own backend, just use an API key.
Register a client
Client registration is currently admin-gated — talk to [email protected] with:
- your app’s label (shown to the customer on the consent screen)
- the redirect URIs you’ll use (HTTPS-only, except
localhostfor native-app development per RFC 8252) - whether the client is account-scoped (one specific customer account) or marketplace-style (any customer can authorize)
Support returns:
{ "client_id": "oac_<base64url>", "client_secret": "oas_<base64url>" }
The client_secret is shown once and never recoverable; the
server stores only its SHA-256 hash. Lost secrets require rotation
via support.
The flow
┌──────┐ ┌────────────┐
│ 3p │ │ Driftstack │
│ app │ │ server │
└──┬───┘ └─────┬──────┘
│ │
│ 1. redirect customer's browser to │
│ GET /v1/oauth/authorize?client_id=…&PKCE=… │
│ ────────────────────────────────────────────────► │
│ │
│ ┌────────────────┐ │
│ 2. dashboard │ customer- │ │
│ renders │ dashboard │ │
│ consent │ (browser) │ │
│ └─────┬──────────┘ │
│ │ approve │
│ ▼ │
│ POST /v1/oauth/authorize/complete │
│ │ │
│ ▼ │
│ 3. redirect to your redirect_uri?code=…&state=… │
│ ◄──────────────────────────────────────────────── │
│ │
│ 4. POST /v1/oauth/token (with code + verifier) │
│ ────────────────────────────────────────────────► │
│ 5. { access_token, token_type, expires_in, │
│ scope } │
│ ◄──────────────────────────────────────────────── │
│ │
│ 6. Authorization: Bearer <access_token> │
│ on subsequent /v1/* requests │
│ ────────────────────────────────────────────────► │
1 — Stage authorization
GET /v1/oauth/authorize
Query parameters (RFC 7636 PKCE — S256 only):
| Parameter | Required | Notes |
|---|---|---|
client_id | yes | your oac_… value |
redirect_uri | yes | must match one of the URIs registered with the client |
state | yes | opaque value 8–256 chars; you’ll receive it back unchanged in step 3 |
code_challenge | yes | 43–128 chars; base64url(SHA-256(code_verifier)) |
code_challenge_method | yes | literal S256 (the plain method is rejected) |
scope | optional | space-separated list from the standard API-key scope set |
Response (200):
{ "authorization_id": "<opaque>" }
In practice your code redirects the customer’s browser to this
endpoint. The dashboard renders the consent screen using the
authorization_id, then posts to /authorize/complete (step 2) on
the customer’s behalf once they approve.
2 — Customer approves (dashboard-internal)
POST /v1/oauth/authorize/complete (dashboard auth required)
Body:
{ "authorization_id": "<from-step-1>", "account_id": "<customer's-uuid>" }
Response:
{ "redirect_to": "https://your-app/callback?code=<opaque>&state=<from-step-1>" }
You shouldn’t call this endpoint directly — the customer-dashboard does. Your job is to receive the redirect at step 3.
3 — You receive the code at your redirect URI
The browser lands on <redirect_uri>?code=<opaque>&state=<your-state>.
Verify the state matches what you stored client-side (CSRF
protection per OAuth spec), then proceed to step 4.
Codes expire 5 minutes after issue and are single-use.
4 — Exchange the code for a token
POST /v1/oauth/token
Body (application/json):
{
"grant_type": "authorization_code",
"code": "<from-step-3>",
"code_verifier": "<your-PKCE-verifier>",
"client_id": "oac_…",
"client_secret": "oas_…",
"redirect_uri": "<same-as-step-1>"
}
Response (200):
{
"access_token": "<opaque>",
"token_type": "Bearer",
"expires_in": 3600,
"scope": ["read", "write"]
}
expires_in is 3600 seconds (1 hour) by design. No refresh tokens
are issued — when the access token expires, the customer must
re-authorize. This keeps the threat model simple (a leaked access
token’s blast radius is bounded by the hour).
Errors (problem+json, 4xx):
invalid_grant— the code is unknown, expired, already used, or thecode_verifierdoesn’t match thecode_challengeinvalid_client— theclient_id+client_secretpair didn’t match (the secret is wrong OR the client has been revoked)invalid_request— the body failed validation (missing field,redirect_urimismatch)
5 — Use the access token
GET /v1/sessions HTTP/1.1
Host: api.driftstack.dev
Authorization: Bearer <access_token>
The scopes you received in the token response gate which endpoints you can call. Use a smaller scope than the customer’s full API key where you can — least-privilege keeps customers comfortable approving the consent screen.
Validating tokens (introspection)
POST /v1/oauth/introspect (RFC 7662)
Body: { "token": "<access_token>" }
Response when the token is active:
{
"active": true,
"client_id": "oac_…",
"account_id": "<customer-uuid>",
"scope": ["read", "write"],
"exp": 1747852800
}
When the token is invalid, revoked, or expired:
{ "active": false }
exp is Unix seconds (per RFC 7662 §2.2). Most third-party clients
don’t need this endpoint (they get all the same info from the
/token response), but it’s useful for resource servers proxying
Driftstack on behalf of a different upstream.
Revoking tokens
POST /v1/oauth/revoke (RFC 7009)
Body:
{ "token": "<access_token>", "token_type_hint": "access_token" }
token_type_hint is informational only. Returns 200 {} always —
even if the token never existed, to prevent probe-style enumeration
per the RFC.
Customers can ALSO revoke your integration from the customer
dashboard at any time, which invalidates all access tokens issued
to your client_id for that account.
Errors at a glance
| Status | Code (problem+json) | When |
|---|---|---|
| 400 | invalid_request | body / query failed validation |
| 400 | invalid_grant | code unknown / expired / already used; PKCE verifier mismatch |
| 400 | invalid_scope | requested scope outside the client’s allowed set |
| 400 | access_denied | customer rejected the consent screen |
| 401 | invalid_client | client_id + client_secret mismatch OR client revoked |
| 401 | unauthorized_client | the client isn’t allowed to use this grant type |
All responses use application/problem+json per RFC 7807 (status,
type, title, detail). The type field is the value in the table
above prefixed with urn:driftstack:oauth:.
Implementation notes
- PKCE is mandatory, including for confidential clients. The
plainchallenge method is rejected —S256only. - Codes are single-use and expire 5 minutes after issue. Race a
second
/tokenexchange with the same code → both fail withinvalid_grant(the code is atomically consumed). - Access tokens are opaque — don’t try to parse them. They’re
not JWTs; introspect via
/v1/oauth/introspectif you need the encoded fields. - Refresh tokens are NOT issued. When a token expires, the customer must re-authorize. This is intentional; refresh tokens are an attack surface and 1-hour TTL access tokens are a workable trade-off for the kinds of integrations Driftstack hosts.
- Same scope set as API keys. The OAuth
scopevalue is parsed throughApiKeyScopeSchema— see the API keys page for the full scope catalog.