Driftstack DRIFTSTACK docs
Docs

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 the Authorization: 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 localhost for 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):

ParameterRequiredNotes
client_idyesyour oac_… value
redirect_uriyesmust match one of the URIs registered with the client
stateyesopaque value 8–256 chars; you’ll receive it back unchanged in step 3
code_challengeyes43–128 chars; base64url(SHA-256(code_verifier))
code_challenge_methodyesliteral S256 (the plain method is rejected)
scopeoptionalspace-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 the code_verifier doesn’t match the code_challenge
  • invalid_client — the client_id + client_secret pair didn’t match (the secret is wrong OR the client has been revoked)
  • invalid_request — the body failed validation (missing field, redirect_uri mismatch)

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

StatusCode (problem+json)When
400invalid_requestbody / query failed validation
400invalid_grantcode unknown / expired / already used; PKCE verifier mismatch
400invalid_scoperequested scope outside the client’s allowed set
400access_deniedcustomer rejected the consent screen
401invalid_clientclient_id + client_secret mismatch OR client revoked
401unauthorized_clientthe 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 plain challenge method is rejected — S256 only.
  • Codes are single-use and expire 5 minutes after issue. Race a second /token exchange with the same code → both fail with invalid_grant (the code is atomically consumed).
  • Access tokens are opaque — don’t try to parse them. They’re not JWTs; introspect via /v1/oauth/introspect if 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 scope value is parsed through ApiKeyScopeSchema — see the API keys page for the full scope catalog.