drift Docs
Start
What is Drift?
The tour, if you are new here.
Use cases
Whether Drift does your thing.
Getting started
Nothing to deployed, in one command.
Architecture
How a slice is put together.
What it costs
The free grant, four unit prices, two rules.
Build
Canvas
Static sites, same origin as your API.
Tools
Operate
Auth
Route gates, API keys and your account.
Security
Boundaries, sandboxing and hardening.
Troubleshooting
Error codes
What went wrong, and what to do about it.
Legal
Acceptable use
What a slice may not be used for.
Data processing
The DPA, and every sub-processor.

Authentication

Three separate things go by the name "auth" on Drift, and keeping them apart saves a lot of confusion. The route gate decides whether a request reaches your function at all. The Deed primitives, JWT and KeyAuth, are what your handler uses to work out who is calling. Your Drift account is a third thing entirely: the session the CLI uses to talk to the platform. This page covers all three, in that order.

The route gate

A function's auth — declared on its Driftfile entry — is checked inside your slice: after the router hands the request over and the route is matched, and before the function subprocess is spawned. Nothing is gated at the platform edge: the edge forwards Authorization and X-API-Key through untouched, precisely so your slice can read them.

ValueBehaviour
auth: none, or omittedPublic. Anyone who can reach the route can call it.
auth: apikeyThe request must present the key configured for that exact method and route.

There is no auth: jwt mode.

The gate understands none and apikey, and nothing else.

The manifest enumerates those two, so drift file lint refuses anything else offline, before a deploy. That check is newer than the platform: a function shipped when auth was free text stored whatever it was given and answers 403 unknown auth type to every request, a valid Bearer token included. To check a token, leave the route at auth: none and verify inside the handler.

API keys

auth=apikey is the machine-to-machine gate: one shared secret per method and route, compared in constant time, stored by the slice and reloaded after a restart.

Managing keys

Shell
drift atomic auth set send-email s3cr3t-value
drift atomic auth set reports/daily s3cr3t-value --method get
drift atomic auth list send-email
drift atomic auth revoke send-email
drift atomic auth revoke reports/daily --method get

Keys are held per method + route. The route is the same identity the platform routes on: element/name for a function inside an element, the bare name otherwise. list shows a last-four fingerprint, never the key.

A key set without --method get protects nothing.

set and revoke default to --method post, so a key for a GET route reports success and guards nothing, it configures a key for a POST route that may not exist.

A configured key outranks the declaration

The effective mode is apikey whenever a key exists for the route, whatever the function declared. So drift atomic auth set locks down a live function that shipped as auth=none, with no redeploy, and revoke opens it back up.

The three refusals

BodyCause
  • 401
    Body
    missing X-API-Key header
    Cause
    No credential presented at all.
  • 403
    Body
    invalid api key
    Cause
    A credential was presented and does not match.
  • 503
    Body
    api key not configured for this function
    Cause
    The route requires a key and none is set.

The 503 is the one most people meet first: declaring auth=apikey and deploying, without running drift atomic auth set, closes the route to everyone including you.

Where the key rides

X-API-Key: <key> is the header to reach for. Authorization is accepted as well (bare, or with a Bearer  or ApiKey  prefix) for clients that cannot set a custom header.

JWT: issue and verify

Tokens are HS256, signed with a 32-byte key unique to your slice. You never see, set or rotate that key: signing and verification both happen inside the slice, and your function only ever holds the finished token. When you leave iss unset it is stamped with your slice's identity, and verification checks it, so a token minted by one slice fails at another.

CallDoes
drift.Deed.JWT.Issue(claims)Mints a signed token. You set sub, exp (required), optional nbf/aud/jti, and a free-form custom map the platform never inspects.
drift.Deed.JWT.Verify(token, opts)Checks signature, algorithm, exp, nbf, issuer and, when you pass one, audience. Returns the decoded claims, custom map included.

exp is required on both sides.

Issuing without one fails, issuing with one in the past fails, and verification rejects a token that carries no expiry. There is no way to mint a session token that never dies.

A failed verify reports a stable reason string: malformed, bad_signature, expired, not_yet_valid, wrong_algorithm, wrong_issuer, wrong_audience, invalid_claims, missing_exp, internal_error. Branch on that, not on the message text.

Login → protected route

Both routes are auth=none. Login checks credentials and mints a token; the protected route verifies it. The gate has no token mode, so the check belongs in the handler, which is where you want it anyway, because that is the only place you can choose the status code, the error body, and what "authorised" means for this particular route.

Go

Go
// Driftfile: name post:login, handler PostLogin
func PostLogin(body map[string]any, req drift.Request) (int, string, any, map[string]string) {
    user, ok := checkPassword(body["email"], body["password"])  // your check, against Backbone
    if !ok {
        return 401, "Unauthorized", map[string]any{"error": "bad credentials"}, nil
    }
    token, err := drift.Deed.JWT.Issue(drift.JWTClaims{
        Sub:    user.ID,
        Exp:    time.Now().Add(24 * time.Hour).Unix(),
        Custom: map[string]any{"role": user.Role},
    })
    if err != nil {
        return 500, "Internal Server Error", map[string]any{"error": "could not issue token"}, nil
    }
    return 200, "OK", map[string]any{"token": token}, nil
}

// Driftfile: name get:me, handler GetMe     // the handler is the gate
func GetMe(req drift.Request) (int, string, any, map[string]string) {
    token := strings.TrimPrefix(req.Headers["Authorization"], "Bearer ")
    claims, err := drift.Deed.JWT.Verify(token, drift.JWTVerifyOptions{})
    if err != nil {
        return 401, "Unauthorized", map[string]any{"error": err.Error()}, nil
    }
    return 200, "OK", claims.Custom, nil
}

Python

Python
# Driftfile: name post:login, handler post_login
def post_login(body, req):
    user = check_password(body["email"], body["password"])
    if not user:
        return 401, "Unauthorized", {"error": "bad credentials"}
    token = drift.deed.jwt.issue(
        sub=user["id"],
        exp=int(time.time()) + 86400,        # 24h
        custom={"role": user["role"]},
    )
    return 200, "OK", {"token": token}

# Driftfile: name get:me, handler get_me
def get_me(req):
    token = req["headers"]["Authorization"].removeprefix("Bearer ")
    try:
        claims = drift.deed.jwt.verify(token)
    except drift.JWTError as e:
        return 401, "Unauthorized", {"error": e.reason}
    return 200, "OK", claims["custom"]

A single claims dict is not what issue wants.

It takes keyword arguments in the interpreted SDKs: sub=, exp=, custom=. Passing a dict binds it to sub, leaves exp unset, and the call fails.

The token rides in the standard Authorization: Bearer <token> header. A browser frontend served from Canvas calls /api/me same-origin and adds that header, with no CORS in the way. Store the token however your client prefers.

Passwordless: KeyAuth

When you would rather not store passwords at all, the KeyAuth Deed primitive builds login on device key pairs (Ed25519). The device generates the pair; its public key is the identity. There is nothing to leak server-side: the slice sees public keys and signatures, and keeps neither a password nor a hash.

  1. Challenge: drift.Deed.KeyAuth.Challenge(pubkey) mints a one-time nonce. Pair it with drift.Deed.JWT.SliceID() and send both to the device, which needs the slice's own identity to sign the next step.
  2. Sign: the client signs the canonical {domain, nonce, pubkey, slice} JSON with its private key, which never leaves the device.
  3. Verify: drift.Deed.KeyAuth.Verify(pubkey, sig, domain) checks the signature and returns one of your slice's ordinary JWTs.
Go
// Driftfile: name post:auth/challenge, handler PostAuthChallenge
func PostAuthChallenge(body map[string]any, req drift.Request) (int, string, any, map[string]string) {
    nonce, err := drift.Deed.KeyAuth.Challenge(body["pubkey"].(string))
    if err != nil {
        return 400, "Bad Request", map[string]any{"error": err.Error()}, nil
    }
    // the device signs against this slice's own identity, so it has to be sent along with the nonce
    return 200, "OK", map[string]any{"nonce": nonce, "slice": drift.Deed.JWT.SliceID()}, nil
}

// Driftfile: name post:auth/verify, handler PostAuthVerify
func PostAuthVerify(body map[string]any, req drift.Request) (int, string, any, map[string]string) {
    token, err := drift.Deed.KeyAuth.Verify(body["pubkey"].(string), body["sig"].(string), "my-app")
    if err != nil {
        return 401, "Unauthorized", map[string]any{"error": "bad signature"}, nil
    }
    return 200, "OK", map[string]any{"token": token}, nil
}

The lifetimes and identities KeyAuth enforces

RuleDetail
Challenge lifetimeThe nonce lives 120 seconds and is burned the moment it verifies. One signature per challenge; a retry needs a fresh one.
Session lifetimeThe returned token expires 30 days out. Shorten it by minting your own with drift.Deed.JWT.Issue instead of handing this one to the client.
DomainTaken exactly as given, with no platform fallback: an empty domain is simply empty, and the device's signature and your Verify call have to agree on the same string. What actually stops a signature made for one slice working on another is the slice field above, which a caller cannot set.
Who sub isThe public key, until that device is enrolled with Link, after which it is the identity, so a second device authenticates as the same sub as the first.
Revoked devicesA device revoked in an identity's registry is refused with 401 device has been revoked, even though its signature is genuinely valid. That is what revocation is for. A registry the slice cannot read answers 503 instead of admitting the device, since the unreadable file might be the one holding its revocation.

What comes back is an ordinary slice JWT, so the protected routes verify it exactly as they verify a password login's token: drift.Deed.JWT.Verify in the handler. Passwordless login slots in without changing anything downstream. Pair it with the Vault primitive for zero-knowledge account recovery.

Your Drift account

Everything above is auth for your app. This is the auth you meet first: the session the CLI holds against the platform. It shares no keys, tokens or storage with anything your slice runs.

The commands

CommandDoes
drift account createCreates an account. --invite-code is the gate while the platform is invite-only.
drift account loginPrompts for the password with echo off. --password-stdin for CI; --password warns, because the value lands in ps output and shell history.
drift account mfaReports whether a second factor is on, and how many recovery codes are left.
drift account mfa enrolTurns on two-factor authentication. Shows the secret for your authenticator app, waits for the first code it produces, then prints ten recovery codes.
drift account mfa disableTurns it off. Asks for your password and a current code (or a recovery code, for a device you no longer have), because a session on its own is what a theft already has.
drift account reset-passwordResets a forgotten password using a code sent by email.
drift account auditShows what the platform recorded about your account: logins, deploys, secret reads, domain changes.
drift account deleteDeletes the account and everything in it. Two confirmations: y/N, then your username typed verbatim. --yes skips both.
drift account token create/list/revokeMints, lists and revokes a scoped access token for CI and scripts, independent of your own session.
drift account member invite/list/removeAdds or removes a second person on the account, restricted to the same narrow scope a token can carry.

Login and signup are capped at 10 attempts per minute per IP address.

Two-factor authentication

Drift speaks TOTP, the six-digit codes any authenticator app produces. Once a factor is enrolled, a correct password no longer returns a session: the platform answers with a short-lived challenge, and a code turns that challenge into tokens.

Shell
drift account mfa enrol
# add the secret to your app, type the code it shows
# ten recovery codes are printed once

drift account login
# Authentication code (or press enter to use a recovery code):
DetailWhat it means
Enrolling is two stepsThe factor turns on only once a code from your app verifies. An app that never received the secret cannot lock you out of your own account.
A code works onceThe time step a login spends is recorded, so the same six digits cannot be used again inside their window.
Recovery codesTen, shown once at enrolment, each usable once in place of an app code. The CLI never writes them to disk; keep them somewhere that is not the machine you log in from.
GuessingFive wrong codes destroys the challenge rather than throttling it. Getting a sixth attempt means entering the password again.
Non-interactive--mfa-code passes a code for CI, and --recovery-code uses a recovery code. With --password-stdin and neither flag, the login fails rather than prompting a pipe.
In the audit trailmfa.challenged means someone entered your password correctly and was stopped by the factor. It is worth reading drift account audit for.

The token pair

TokenShape and lifetime
AccessRS256, claims {username, exp, iat, origin:"cli", scopes} (plus actor when the caller is a member rather than the owner), 15-minute TTL. Sent as Authorization: Bearer on every command.
Refresh64 random bytes, 30-day TTL. The platform stores only a SHA-256 hash of it, so the store cannot hand back a usable token.

Refresh is rotation-on-use: each refresh revokes the token presented and issues a replacement, recording the link between them. Replaying a spent token is read as a compromise and revokes every live token for the account, forcing a fresh login everywhere. The CLI does this for you: a 401 on any command triggers one refresh and one retry, invisibly.

Both login and refresh carry a per-workstation device_id. A refresh whose device does not match the one recorded at login is treated the same as replay: every live token for the account is revoked.

Personal access tokens and scopes

Your own session can do everything your account can. A CI pipeline or a script should not have to run under that, so an access token carries a narrower, named set of permissions and is revoked on its own, without touching your password.

Shell
drift account token create ci --scope slice:read --scope slice:write
drift account token list
drift account token revoke ci

The value is printed once, at creation. Drift stores only its hash, so there is no command that can show it again. Use it without ever logging in:

Shell
export DRIFT_TOKEN=drift_pat_...
export DRIFT_SLICE=my-slice
drift file apply
ScopeGrants
slice:readSee a slice's shape, status, functions, logs and history.
slice:writeCreate, resize, deploy, roll back and delete, and write to Backbone.
secret:readRead secret values in plaintext, and list which ones exist.
secret:writeSet and delete secrets, without being able to read them.
account:readRead the account's own audit trail and its list of live tokens.

Exact match, no implication.

slice:write does not grant slice:read; secret:write does not grant secret:read. A route checks for one literal scope string, so what a token can do is precisely the set you named when you minted it, never wider.

Two scopes can never be minted onto a token at all: account:write (turning your own second factor on or off) and account:delete. A token holding either could undo every other limit you set on it, or end the account it was given to deploy into, so both stay acts for a person, in a session, who has logged in.

Revoking takes effect within about fifteen minutes: a personal access token is itself long-lived and opaque, and every call exchanges it for one of the ordinary 15-minute access tokens above before doing anything, so revoking stops new exchanges while letting one already in flight run out on its own. drift account token list shows every token you have minted, revoked and expired ones included, so "was there a credential that could do this, and when did it stop" always has an answer.

The same scopes back a second person on the account, not only a script: drift account member invite <email> adds a colleague who logs in as themselves and is granted exactly slice:read and slice:write. They deploy and operate; they cannot read or change your secrets, read your audit trail, mint a token, invite anyone else, or delete the account. Removing a member ends their access to your account, not their own account: their login, password and second factor stay theirs.

What lives on your disk

ModeHolds
  • ~/.drift/
    Mode
    0700
    Holds
    none
  • ~/.drift/session.json
    Mode
    0600
    Holds
    The access token, the refresh token, and the active slice.
  • ~/.drift/device_id
    Mode
    0600
    Holds
    The workstation identifier the two tokens are bound to.

A stolen session locks out the thief and the owner together.

A copied session.json without the matching device_id is locked out at the first refresh, and locks the real owner out at the same moment, which is the intent: a theft you are told about beats a theft you are not.

Atomic guide → · Deed reference → · SDK reference →