Sign-in and Sessions
Five ways into an account, one session model underneath. Whichever route a user takes, success ends in the same payload: an access token good for 15 minutes, a refresh token, and the user record.
| Route | Endpoint | Credential needed |
|---|---|---|
| Email and password | POST /auth/signin/email | password |
| Magic link | POST /auth/signin/magic-link then /auth/magic-link/verify | mailbox access |
| OAuth provider | POST /auth/signin/oauth then the provider callback | provider account |
| Passkey | POST /auth/passkey/authenticate/options then /verify | authenticator |
| CLI browser flow | POST /auth/cli/session then poll | an existing session |
Password sign-in
curl -X POST -H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"..."}' \
http://localhost:4000/auth/signin/email{
"access_token": "eyJ0eXAiOiJKV1...",
"refresh_token": "fb0a706a5741...",
"expires_in": 900,
"token_type": "Bearer",
"requires_2fa": false,
"user": { "id": "xhMLHYzgKQPSnTSsfcmz", "email": "you@example.com" }
}The two-factor challenge
When the account has TOTP enabled the same call returns requires_2fa: true
with access_token and refresh_token present but empty strings, and
expires_in: 0. Do not treat a non-null token field as success; check
requires_2fa. The usable value is two_factor_token, which you redeem
within five minutes:
curl -X POST -H "Content-Type: application/json" \
-d '{"two_factor_token":"6ceaaa29...","code":"000328"}' \
http://localhost:4000/auth/2fa/verifyA backup code goes to a different endpoint and a differently named field -
POST /auth/2fa/backup/verify takes backup_code, not code. Sending
code there is 400 BAD_REQUEST: missing field backup_code.
Both code types are single-use. Replaying a TOTP code that already
succeeded, even inside its own 30-second window, is
401 invalid_totp_code; reusing a backup code is
401 backup_code_already_used.
The CLI prompts for the second factor interactively, so a scripted login
fails with 2FA code (or backup code) is required unless you pipe it in:
echo "$TOTP_CODE" | snug auth login --email you@example.com --password '...'Enrolling a second factor
snug auth 2fa setup # returns secret, otpauth URI, backup codes
snug auth 2fa verify-setup --code 123456 # nothing is enabled until this succeeds
snug auth 2fa status
snug auth 2fa disable --code 123456setup alone does not turn 2FA on; it stages a secret and ten backup codes
shaped xxxx-xxxx-xxxx-xxxx. Capture them at that moment, because only
verify-setup commits the enrollment and nothing shows the secret again.
Regenerating codes (POST /api/v1/auth/2fa/backup/regenerate) replaces all
ten and, like disable, requires a current TOTP code in the body as
{"code": "123456"}. Since a code is consumed once accepted, two such calls
inside one 30-second window will not both work - wait for the next code.
Passkeys
Registration and authentication are ordinary WebAuthn: ask for options, hand them to the browser, post back what the authenticator signed.
curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
-H "Content-Type: application/json" -d '{}' \
http://localhost:4000/api/v1/auth/passkey/register/options{
"challenge_id": "-knteTCx9Okgd7pJdLtiY",
"options": {
"publicKey": {
"attestation": "none",
"authenticatorSelection": { "residentKey": "preferred", "userVerification": "required" },
"challenge": "rLoOwmcRVxgp..."
}
}
}Return the challenge_id alongside the credential to
/api/v1/auth/passkey/register/verify. Sign-in mirrors this against
/auth/passkey/authenticate/options and /verify, which are public because
the passkey is the credential; registered keys are listed, renamed, and
deleted through /api/v1/auth/passkeys. The verify halves need a real
authenticator, so they are described from the contract rather than executed -
their request bodies are in the
API reference.
Magic links and OAuth
snug auth magic-link request --email you@example.com
snug auth account providers # which OAuth providers this deployment offersBoth are deliberately quiet about who exists. A magic link for an unknown
address returns the same A magic link has been sent. as a real one, and
POST /auth/password/forgot answers
If an account exists with that email, a password reset link has been sent.
either way. Delivery failures are not surfaced either, so a broken mail
provider looks exactly like a successful send.
account providers returns {"providers": []} where none are configured,
and POST /auth/signin/oauth has nothing to redirect to until one is
seeded. Linking on an existing account is POST /api/v1/auth/account/link
and /unlink, with GET /api/v1/auth/accounts listing what is attached.
Signing in a CLI through a browser
For a machine that cannot show a login form: the CLI opens a session, the user completes it in a browser, and the CLI polls.
curl -X POST -H "Content-Type: application/json" -d '{}' \
http://localhost:4000/auth/cli/session{
"session_id": "tan2Uu8jqleOILjOTDKUt",
"poll_secret": "743552ce34f8...",
"login_url": "/auth/cli/login?session=tan2Uu8jqleOILjOTDKUt",
"expires_in": 300
}The poll query parameter is named secret, not poll_secret:
GET /auth/cli/session/{id}/poll?secret=.... It answers
{"status": "pending"} until an authenticated browser posts to
/api/v1/auth/cli/session/{id}/complete, then returns the token pair nested
under tokens. That payload is delivered once - the next poll, and any poll
with a wrong secret, is 404.
Session lifecycle
snug auth session list
snug auth session revoke --session-id <id>
snug auth refresh --refresh-token <token>
snug auth logoutsession list is a history, not a live roster: revoked and superseded
sessions stay in it with active: false. Revocation
(DELETE /api/v1/auth/sessions/{id}) and signout both answer 204 with no
envelope and take effect at once - the access token that worked a second
earlier returns 401 session has been revoked, because session state is
consulted on every request rather than trusted until expiry.
Refreshing rotates: the response carries a new refresh token and retires the
one you sent, so a client that keeps the old value gets
401 invalid_refresh_token next time. After a signout the refresh token
reports 401 session_revoked instead.
Passwords and email verification
snug auth password change # prompts for current and new
snug auth password forgot --email you@example.com
snug auth password reset --token <token> --new-password '...'
snug auth email verify-sendChanging a password ends every session, including the one making the
request, and revokes every API key the user holds. The call returns 200,
the very next call with that same access token is 401, and the account's
keys start reporting 401 api_key_revoked. Reset does the same. Sign in
again with the new password afterwards and reissue any keys that mattered; a
wrong current password is 401 invalid_credentials and a weak new one is
400 weak_password.
Two distinct errors mean "verify your email", from different layers. Service
endpoints are gated by shared middleware and answer
403 EMAIL_NOT_VERIFIED; auth's own organization endpoints check the stored
user record and answer 403 email_not_verified. The second is not bypassed
by a locally minted token, so snug jwt generate gets you into KV but not
into organization creation.
POST /api/v1/auth/email/verify/send requires a JSON body and a
Content-Type: application/json header even though it has no meaningful
fields - send {}, or it fails with
400 Expected request with Content-Type: application/json.
Reference
- Auth API - request and response bodies for every endpoint above
- Auth service overview - concepts, API keys, and the other task pages