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.
| Value | Behaviour |
|---|---|
auth=none, or omitted | Public. Anyone who can reach the route can call it. |
auth=apikey | The request must present the key configured for that exact method and route. |
| anything else | Every 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
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
- Body
missing X-API-Key header- Cause
- No credential presented at all.
- Body
invalid api key- Cause
- A credential was presented and does not match.
- 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.
| Call | Does |
|---|---|
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
// @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
# @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.
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.
- Challenge:
drift.Deed.KeyAuth.Challenge(pubkey)mints a one-time nonce. - Sign: the client signs the canonical
{domain, nonce, pubkey}JSON with its private key, which never leaves the device. - Verify:
drift.Deed.KeyAuth.Verify(pubkey, sig, domain)checks the signature and returns one of your slice's ordinary JWTs.
// @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
| Rule | Detail |
|---|---|
| Challenge lifetime | The nonce lives 120 seconds and is burned the moment it verifies. One signature per challenge; a retry needs a fresh one. |
| Session lifetime | The 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. |
| Domain | An 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 is | The 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 devices | A 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
| Command | Does |
|---|---|
drift account create | Creates an account. --invite-code is the gate while the platform is invite-only. |
drift account login | Prompts 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-password | Resets a forgotten password using a code sent by email. |
drift account delete | Deletes 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
| Token | Shape and lifetime |
|---|---|
| Access | RS256, claims {username, exp, iat, origin:"cli"}, 15-minute TTL. Sent as Authorization: Bearer on every command. |
| Refresh | 64 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
- Mode
0700- Holds
- none
- Mode
0600- Holds
- The access token, the refresh token, and the active slice.
- Mode
0600- Holds
- The workstation identifier the two tokens are bound to.
A stolen session locks out the thief and the owner together.
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.