Driftstack DRIFTSTACK docs
Docs

Authentication flows

Driftstack has three auth surfaces:

  1. Customer API-key bearer auth for SDK consumers on any paid tier, including Manual — covered in API keys.
  2. Web-session auth for the customer dashboard — covered here. Email + password (or magic link), optional TOTP, exchanged for an opaque session token stored in the dashboard’s local storage.
  3. Browser-authorized device credentials for the desktop app. On Free, this restricted ds_test_… credential is stored automatically and is limited to the supported desktop route surface; it is not a general sandbox/customer key.

All three use the same Authorization: Bearer <token> header. Paid customer keys use ds_live_…; the desktop device flow returns a provenance-bound credential (ds_test_… on Free); web sessions are opaque base64 tokens. The server enforces the stored credential type and account tier as well as the token shape.

Ordinary customer API keys and OAuth access tokens are rejected while their account is on Free, even if authentication was previously cached. They resume after an upgrade unless separately revoked or expired. The response is the normal RFC 9457 403 Forbidden, with actionable detail: The "apiAccess" feature is not available on the "free" tier. Upgrade to a tier that includes this feature.

Sign up

POST /v1/auth/signup

{
  "email": "[email protected]",
  "password": "<min 12 chars>",
  "name": "Acme B.V."
}

Returns 200 with verification_email_expires_at (ISO timestamp). The link’s token expires at the timestamp returned.

There is no unverified account status — status is one of active, suspended, deleted, and a new signup is created active. What verification changes is email_verified_at, which starts null and is stamped when the customer clicks the link. Gates that care about verification test that field, not status; login refuses while it is null.

409 Conflict is returned when email is already registered.

Verify email

POST /v1/auth/verify-email

{ "token": "<from the verification email>" }

Returns a discriminated union, the same shape as login:

  • No MFA enrolled200 with a fresh web session:
    {
      "session": {
        "token": "<opaque base64>",
        "expires_at": "2026-05-23T22:00:00.000Z",
        "account_id": "acc_<uuid>"
      }
    }
  • MFA enrolled200 with a challenge instead of a session:
    {
      "mfa_required": true,
      "challenge_token": "<one-time, expires in 5 minutes>",
      "challenge_expires_at": "2026-05-09T22:35:00.000Z"
    }

Verifying an email proves control of the mailbox, not possession of the account’s second factor, so an enrolled account gets a challenge here exactly as it does on login, magic-link/consume and password-reset/confirm. Exchange it at /v1/auth/mfa/challenge as described below. The email is marked verified either way — only the session waits for the second factor.

Branch on the mfa_required literal, never on the presence of session.

The dashboard stores session.token in local storage and uses it as the bearer for every subsequent /v1/* request. Verifying email stamps email_verified_at; it does not change status, which was already active. That stamp is what lets the customer sign in directly afterward, since login refuses an unverified address.

Resend verification email

POST /v1/auth/resend-verification

{ "email": "[email protected]" }

Self-service re-send of the signup verification email, for when the original expired or never arrived. Returns 200 with the new token’s expires_at:

{ "sent": true, "expires_at": "2026-05-23T22:00:00.000Z" }

The response shape is identical whether the email matched an unverified account, an already-verified account, or no account at all — the server silently no-ops in the latter two cases, so the wire never leaks account existence (same no-enumeration posture as magic-link and password-reset). Because each call can trigger an email send, the endpoint is tightly rate-limited per IP (the same budget as password-reset requests).

Log in

POST /v1/auth/login

{ "email": "[email protected]", "password": "<password>" }

Returns a discriminated union:

  • No MFA enrolled — same shape as verify-email:
    { "session": { "token": "...", "expires_at": "...", "account_id": "..." } }
  • MFA enrolled — challenge token returned; the dashboard drops into the second-factor UI:
    {
      "mfa_required": true,
      "challenge_token": "<one-time, expires in 5 minutes>",
      "challenge_expires_at": "2026-05-09T22:35:00.000Z"
    }

Branch on the mfa_required literal. When it’s present + true, do not store anything — wait for the customer to enter their TOTP code and call the challenge endpoint below.

SDK usage (type narrowing + MFA exchange):

// TypeScript — discriminated-union return type narrows automatically.
const out = await client.auth.login({ email, password });
if ('mfa_required' in out && out.mfa_required) {
  // out: LoginMfaRequiredResponse — challenge_token + challenge_expires_at typed.
  const exchange = await client.auth.mfaChallenge({
    challenge_token: out.challenge_token,
    code: userTotpCode,
  });
  store(exchange.session.token);
} else {
  // out: LoginResponse — out.session is the real session.
  store(out.session.token);
}
# Python — dict-shape, branch on the same key.
out = client.auth.login({"email": ..., "password": ...})
if out.get("mfa_required"):
    exchange = client.auth.mfa_challenge({
        "challenge_token": out["challenge_token"],
        "code": user_totp_code,
    })
    session = exchange["session"]
else:
    session = out["session"]
// Go — LoginResponse carries both branches; check MfaRequired.
out, err := client.Auth.Login(ctx, &driftstack.LoginRequest{Email: e, Password: p})
if err != nil { return err }
if out.MfaRequired {
    exchange, err := client.Auth.MfaChallenge(ctx, &driftstack.MfaChallengeRequest{
        ChallengeToken: out.ChallengeToken,
        Code:           userTotpCode,
    })
    if err != nil { return err }
    // exchange.Session.Token is the real session.
} else {
    // out.Session.Token is the real session.
}

MFA challenge

POST /v1/auth/mfa/challenge

{
  "challenge_token": "<from the login response>",
  "code": "123456" // OR "recovery_code": "ABCDE-FGHJK"
}

Returns the same session shape as a non-MFA login. The discriminator via: "totp" | "recovery" indicates which factor was used; recovery_code consumption decrements unused_recovery_codes on the account and is recorded as account.recovery_code_used in the audit log with payload.remaining.

MFA step-up

POST /v1/auth/mfa/step-up

Refreshes mfa_satisfied_at on the calling web session. Used by the dashboard when a sensitive operation (disable MFA, regenerate recovery codes, delete account) requires re-asserting the second factor within a 15-minute freshness window.

{ "code": "123456" }

Returns 200; no new session issued — the existing session row gets mfa_satisfied_at = now().

For customers who prefer email-based sign-in over password:

POST /v1/auth/magic-link/request with { "email": "..." }. Always returns 200 regardless of whether the address matches an account (no account-enumeration signal). When the address does match, an email is delivered with a one-time link.

POST /v1/auth/magic-link/consume with { "token": "..." } from the link returns the same discriminated union as password login: a normal session when MFA is not enrolled, or mfa_required plus a one-time challenge token when it is. The enrolled branch mints no session until the caller completes POST /v1/auth/mfa/challenge; mailbox access is the first factor, not a bypass for TOTP or recovery-code proof.

Password reset

POST /v1/auth/password-reset/request with { "email": "..." }. Same no-enumeration semantics as magic-link: always 200.

POST /v1/auth/password-reset/confirm:

{ "token": "<from email>", "new_password": "<new password>" }

Changes the password and invalidates ALL prior sessions for the account. It then returns the same discriminated union as login:

  • without enrolled MFA, a fresh session is issued;
  • with enrolled MFA, mfa_required is returned and no replacement session is minted until POST /v1/auth/mfa/challenge succeeds.

Every prior device must re-authenticate. The reset-confirming device is logged in only after it receives the no-MFA session branch or successfully exchanges the MFA challenge.

Refresh

POST /v1/auth/refresh

{ "token": "<existing session token>" }

Issues a fresh session token with a new expires_at. The previous token is invalidated. Use this to keep dashboard sessions alive without re-prompting for credentials.

Logout

POST /v1/auth/logout

{ "token": "<session to revoke>" }

Returns 200 with { "ok": true }. Subsequent requests with that token return 401 Unauthorized.

Sessions list + revoke

For “active sign-ins” management, see Account and the /v1/account/web-sessions endpoints — they let customers see every device currently signed in and revoke any individual session or every-other.

Desktop device activation flow

Browser authorization lets the desktop app obtain a restricted device credential without asking the user to copy/paste a customer API key. The dance is three steps — Initiate, Bind, then Exchange — each backed by one endpoint below.

Initiate activation

POST /v1/auth/cli-authorize/initiate

Step 1 — Initiate — the desktop app generates a CSRF nonce + optional client label, calls POST /v1/auth/cli-authorize/initiate, and gets back a one-shot code, a separate device-displayed user_code, and a browser_url that opens the dashboard’s Authorize page.

Bind activation (dashboard)

POST /v1/auth/cli-authorize/bind-device-code

Step 2 — Bind — the user signs in to the dashboard (if not already), types the user_code shown by the initiating device, and clicks Authorize. The dashboard hits POST /v1/auth/cli-authorize/bind-device-code with the user’s web-session bearer; the server mints a provenance-bound device credential on the calling account and stores only its encrypted envelope under a hashed code identifier (Redis, 2-minute post-bind TTL).

Exchange for the device credential

POST /v1/auth/cli-authorize/exchange

Step 3 — Exchange — the desktop app polls POST /v1/auth/cli-authorize/exchange until the response transitions from { status: "pending" } to { status: "bound", api_key, account_id }. Bound is one-shot: the server deletes the code as it hands back the key, so a subsequent poll returns { status: "expired" } (HTTP 200). The same { status: "expired" } is returned if the user takes too long; either way the desktop app restarts the flow.

CSRF state

The state parameter is a client-supplied 16-128 character random nonce. The dashboard echoes it back; the server verifies it matches on bind — defends against the dashboard being tricked into binding a code that wasn’t issued in the same session.

SDK example

const { code, user_code, browser_url } = await client.auth.cliAuthorizeInitiate({
  state: crypto.randomUUID(),
  client_label: 'Driftstack Desktop on darwin-arm64',
});
console.log(`Enter ${user_code} in the browser to approve this device.`);
open(browser_url); // open in system browser

for (;;) {
  const out = await client.auth.cliAuthorizeExchange({ code, state });
  if (out.status === 'bound') {
    saveApiKey(out.api_key);
    break;
  }
  if (out.status === 'expired') throw new Error('User took too long');
  await sleep(2000);
}
out = client.auth.cli_authorize_initiate({
    "state": secrets.token_urlsafe(24),
    "client_label": "Driftstack Desktop",
})
print(f'Enter {out["user_code"]} in the browser to approve this device.')
webbrowser.open(out["browser_url"])

while True:
    poll = client.auth.cli_authorize_exchange({
        "code": out["code"],
        "state": state,
    })
    if poll["status"] == "bound":
        save_api_key(poll["api_key"])
        break
    if poll["status"] == "expired":
        raise RuntimeError("expired")
    time.sleep(2)
init, _ := client.Auth.CliAuthorizeInitiate(ctx, &driftstack.CliAuthorizeInitiateRequest{
    State:       state,
    ClientLabel: "Driftstack Desktop",
})
fmt.Printf("Enter %s in the browser to approve this device.\n", init.UserCode)
exec.Command("open", init.BrowserURL).Run()

for {
    poll, _ := client.Auth.CliAuthorizeExchange(ctx, &driftstack.CliAuthorizeExchangeRequest{
        Code:  init.Code,
        State: state,
    })
    if poll.Status == "bound" {
        saveAPIKey(poll.APIKey)
        break
    }
    if poll.Status == "expired" {
        return errors.New("expired")
    }
    time.Sleep(2 * time.Second)
}

Default scopes and Free restrictions

The minted device credential carries ["account_owner"] scope by default. Device clients that only need read access should pass scopes: ["read"] on the bind call to follow least-privilege; the desktop app drives sessions end-to-end and keeps the default. On Free, the server additionally restricts this credential to the registered desktop route allowlist. Its broad scope does not turn it into a general-purpose customer API key.

Auth + scoping

None of /v1/auth/* honors the team-RBAC X-Driftstack-Account header — auth is always per-credential, not per-team-context. The team header is only consulted on /v1/* endpoints that operate on resources (sessions, profiles, webhooks, …). See Team RBAC for the full list.