drift Docs
Start
What is Drift?
The tour, if you are new here.
Why Drift?
The case for a smaller cloud.
Getting started
Nothing to deployed, in one command.
Architecture
How a slice is put together.
What it costs
The free grant, five unit prices, two rules.
Build
Canvas
Static sites, same origin as your API.
Tools
Operate
Auth
Accounts, tokens and scopes.
Security
Boundaries, sandboxing and hardening.

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

The auth keyword in an @atomic directive 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.
anything elseEvery request is refused with 403 {"error":"unknown auth type"}.

There is no auth=jwt mode.

The gate understands none and apikey, and nothing else.

The @atomic parser accepts any auth= value and the platform stores it verbatim, so a function declared auth=jwt deploys cleanly and then 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
// @atomic http=post:login auth=none
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
}

// @atomic http=get:me auth=none            // 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
# @atomic http=post:login auth=none
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}

# @atomic http=get:me auth=none
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.
  2. Sign: the client signs the canonical {domain, nonce, pubkey} 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
// @atomic http=post:auth/challenge auth=none
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
    }
    return 200, "OK", map[string]any{"nonce": nonce}, nil
}

// @atomic http=post:auth/verify auth=none
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.
DomainAn empty domain silently falls back to drift-keyauth-v1 rather than failing. Pass your own so a signature meant for one app cannot be replayed at another.
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.

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 reset-passwordResets a forgotten password using a code sent by email.
drift account deleteDeletes the account and everything in it. Two confirmations: y/N, then your username typed verbatim. --yes skips both.

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

The token pair

TokenShape and lifetime
AccessRS256, claims {username, exp, iat, origin:"cli"}, 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.

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 →