Authentication

Graph-native auth with users and roles stored in auth.* schema-namespaced collections, synced to graph nodes.

Login

curl -X POST http://localhost:7474/auth/login \
  -d '{"username": "admin", "password": "anvil"}'

# Response: { "idToken": "eyJ...", "refreshToken": "...", "accessToken": "eyJ..." }

Use the Token

curl http://localhost:7474/db/query \
  -H "Authorization: Bearer eyJ..." \
  -d '{"query": "MATCH (n) RETURN n"}'

# Values travel in "params" and bind to $name - never spliced into the query text
curl http://localhost:7474/db/query \
  -H "Authorization: Bearer eyJ..." \
  -d '{"query": "MATCH (l:Lead) WHERE l.name = $name RETURN l LIMIT $n", "params": {"name": "Acme", "n": 10}}'

Register / Refresh / Change Password

POST /auth/register   -- { username, password, email }
POST /auth/refresh    -- { refresh_token }
POST /auth/change-password -- { old_password, new_password }

Key Features

FeatureDetail
JWT RS256Asymmetric signing, persistent key pair (data/jwt_key.pem)
JWKSGET /.well-known/jwks.json for external verification
Argon2idOWASP-recommended password hashing
Refresh tokens7-day refresh, 1-hour access tokens
Schema collectionsauth.users, auth.roles, auth.user_roles, auth.refresh_tokens
Graph sync:User and :Role nodes with [:HAS_ROLE] relationships
Forced password changeDefault admin must change password on first login

Passwordless Login (OTP)

Email-based login that doesn't require a password. The user requests a 6-digit code sent to their inbox, then exchanges it for a JWT - same token shape as the password-login flow.

# 1. Request a code (no auth required). "app" is optional: the code then goes out
#    with that app's templates and settings, and remembers the app.
curl -X POST http://localhost:7474/auth/otp/request \
  -d '{"email":"alice@example.com", "app":"crm"}'
# => { "expires_in_seconds": 300, "message": "If an account ... has been sent." }

# 2. Exchange the code for a JWT
curl -X POST http://localhost:7474/auth/otp/verify \
  -d '{"email":"alice@example.com","code":"482901"}'
# => { "accessToken": "...", "idToken": "...", "refreshToken": "...", "mustChangePassword": false }
SettingDefaultDescription
auth.email.otp_ttl_secs300How long a code is valid
auth.email.otp_max_attempts3Lockout after this many wrong codes for the same OTP
auth.allow_otp_registrationfalseAuto-create a user when verify is called for an unknown email. Scopable per app: a code requested with that app honours the app's value

Auto-Registration

With auth.allow_otp_registration = true, an OTP verify for an email that isn't in auth.users auto-creates the account with the reader role, email_verified: true, and no password. Disabled by default - accidentally exposing a passwordless onboarding path to anyone with a working SMTP inbox is rarely what you want.

The flag is scopable per app (SET SETTING 'app.crm.auth.allow_otp_registration' = 'true' or the app's settings API). A code requested with "app": "crm" registers a first-time email when the server-wide flag or the app's effective flag is on. The verify step takes no app field; it learns the app from the code.

When the flag is off, an unknown-email verify returns the same 401 "Invalid or expired code" as a wrong code. Identical response shapes prevent the endpoint from being used to enumerate registered addresses.

Username Collisions

The username doubles as the document key in auth.users, so it has to be unique. On auto-registration the server tries the email's local part first - e.g. alice@example.com → alice. If that's already taken (because a different person at a different domain shares the local part, or someone manually registered the name), the server appends a 6-char hex suffix and retries: alice-a3f9d2. Retries are capped at 10 attempts; the suffix space is 24 bits, so any realistic user base won't hit that ceiling.

The provisional user doc carries username_provisional: true so a UI can prompt the new user to choose a permanent handle. Both auth.users and auth.user_roles are keyed on the resolved (potentially suffixed) name, so role lookup stays in sync without any extra steps.

Service Accounts & API Keys

Long-lived credentials for CI, scripts, and machine clients. A service account holds a role set and zero or more API keys; each key can narrow that role set with a scope allowlist and may be revoked independently of the account.

An account is either app-scoped or server-wide. Without service_role it is app-scoped: its roles are its privilege inside the apps listed in apps (the highest of reader / editor / admin) and nowhere else. An app-scoped admin administers those apps - data, members, settings, email templates - but is not a server admin: other apps, the public / auth / system schemas, storage, functions and /admin/* are all refused. With service_role the account is server-wide and its roles mean what they mean for a user.

# Create an account confined to the crm app, as its admin
curl -X POST http://localhost:7474/auth/service-accounts \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -d '{"name": "ci-bot", "roles": ["admin"], "apps": ["crm"]}'

# Mint a key - the plaintext is returned ONCE, only stored as a hash after
curl -X POST http://localhost:7474/auth/service-accounts/$ID/keys \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -d '{"name": "github-actions"}'
# => { "key": "anvil_sk_...", "key_id": "...", "prefix": "anvil_sk_xxxxxx" }

# Use the key like any Bearer token. With one granted app the request runs in
# that app's schema; with several, pick one with X-Anvil-App: <slug>.
curl http://localhost:7474/db/query \
  -H "Authorization: Bearer anvil_sk_..." \
  -d '{"query": "MATCH (n:Lead) RETURN n"}'

The same from Cypher, for server admins. Each statement runs the REST handler it mirrors, so validation, app grants and audit events are identical; the one-time secret comes back as a result row.

CREATE SERVICE ACCOUNT ci-bot ROLES admin APPS crm DESCRIPTION 'CI runner'
CREATE SERVICE ACCOUNT sys-bot ROLES editor SERVICE_ROLE   -- server-wide
CREATE API KEY deploy FOR ci-bot EXPIRES 30d               -- 30d | 12h | 45m | NEVER (default)
CREATE API KEY ro FOR ci-bot SCOPES reader                 -- narrowed key
SHOW SERVICE ACCOUNTS
SHOW API KEYS FOR ci-bot                                   -- metadata only, never the secret
SERVICE ACCOUNT ci-bot GRANT APP shop
SERVICE ACCOUNT ci-bot REVOKE APP shop
REVOKE API KEY deploy FOR ci-bot                           -- by name, or by key id
DROP SERVICE ACCOUNT ci-bot                                -- keys and grants go with it
CapabilityDetail
Key prefixanvil_sk_ - the auth middleware uses this to distinguish keys from JWTs
Storageauth.service_accounts and auth.api_keys collections, protected by seeded admin-only RLS policies (select / insert / update / delete). Like every auth.*, system.* and storage.* collection they are closed to non-admins on the Cypher document path too.
Cypher DDL authorizationServer admin AND allowed by those RLS policies - narrow a policy (with FORCE ROW LEVEL SECURITY, since admin otherwise bypasses RLS) and the statement is refused. App admins and app-scoped service accounts cannot run it.
App scopingWithout service_role, roles apply only inside the granted apps (auth.app_members rows keyed by the account id); a scoped key also caps the app privilege. Reachable surface: /db/query, /docs/app_<slug>.*, /apps/<slug>/* and the object routes of its apps' buckets (Buckets under File Storage) - everything else is 403.
ScopesPer-key allowlist; intersected with the account's roles at request time
service_roleMakes the account server-wide and bypasses RLS when both the account and key scope allow it; cannot be combined with apps
AuditingAuthEvents emitted on create / use / revoke (prefix-only, never logs the secret)
Throttled last-usedlast_used_on updates are coalesced to avoid write amplification

Email Delivery (SMTP)

Verification, OTP, and password-reset emails ship through a built-in SMTP client with TLS. Configure under [auth.email] in anvil.toml, override via ANVIL_SMTP_* env vars, or hot-reload through system.settings.

[auth.email]
smtp_host = "smtp.resend.com"
smtp_port = 465                  # 465 = implicit TLS, 587 = STARTTLS
smtp_user = "resend"
smtp_pass = "re_xxxxxxxxxxxxxxxx"
smtp_from = "Anvil DB <noreply@example.com>"
smtp_starttls = true             # only consulted when port != 465

TLS Modes

PortModeWhen to use
465Implicit TLSResend, Mailgun TLS endpoints - handshake happens before SMTP
587STARTTLS upgradeGmail, Office 365, most relays - plaintext greeting then upgrade
25 / otherPlaintextDev-only - set smtp_starttls=false to opt in

TLS uses tokio-rustls with Mozilla's webpki-roots trust store. SMTP replies are parsed across multi-line250- capability lists, and message bodies are dot-stuffed (RFC 5321 §4.5.2) so payload lines beginning with . don't terminate the DATA section early.

Webhook Alternative

Set ANVIL_EMAIL_WEBHOOK_URL (or webhook_url in the config) to POST email payloads as JSON to an external provider instead of speaking SMTP directly. Useful when an outbound TCP relay isn't an option.

Social Login (OAuth)

Let people sign in - and sign up - with a social provider. LinkedIn is the first provider; the flow is standard OAuth 2.0 / OpenID Connect, so the same shape extends to others. Credentials are configurable globally and per app: an app can carry its own OAuth client, and anything it doesn't override falls back to the server-wide default - the same pattern as email.

Configure

[auth.oauth.linkedin]
oauth_linkedin_enabled = true
oauth_linkedin_client_id = "your-linkedin-client-id"
oauth_linkedin_client_secret = "your-linkedin-client-secret"
# redirect_url defaults to {base_url}/auth/oauth/linkedin/callback
# oauth_linkedin_redirect_url = ""

# Extra client origins trusted to receive tokens (loopback and same-origin
# as base_url are always allowed). Comma-separated.
# oauth_redirect_allowlist = "https://hammer.example.com"

Every key is also a runtime setting, so you can set it live with SET SETTING / the settings API - globally, or per app as app.<slug>.auth.oauth.linkedin.*. Register {base_url}/auth/oauth/linkedin/callback as an authorized redirect URL in your LinkedIn developer app, and grant the openid, profile, and email scopes.

Endpoints

EndpointPurpose
GET /auth/oauth/providersWhich providers are configured - { linkedin: bool }. Drives the login UI.
GET /auth/oauth/linkedin/startBegin the flow. Optional ?app=<slug> and ?redirect=<url>; 302s to LinkedIn.
GET /auth/oauth/linkedin/callbackLinkedIn returns here; exchanges the code, signs the user in, issues the JWT trio.

On success the callback either returns the JSON { idToken, refreshToken, accessToken } or, when start was given a redirect, 302s back to it with the tokens in the URL fragment (so they never land in server logs or a Referer).

Sign-up behaviour

A first social login on an unknown email auto-creates a reader (email verified, no password, provisional username) - the same provisioning as passwordless OTP. Turn it off with auth.allow_oauth_registration = false to restrict social login to already-registered users.

A CSRF state is minted per attempt and consumed once, and the client redirect is guarded against open redirects: only loopback, the same origin as your base URL, or an origin you list in auth.oauth.redirect_allowlist is accepted. In the Hammer UI a Continue with LinkedIn button appears automatically whenever the connected server has it configured.