Auth Add-on
Built-in user authentication — registration, login, magic links, and JWT issuance — all self-hosted inside your SapixDB agent. No Supabase. No Auth0. No data leaving your server.
SAPIX_AUTH_ENABLED=trueIncluded in the
enterprise build. No recompilation required.Requires a valid
SAPIX_LICENSE_KEY — without one, all auth endpoints return 402 Payment Required. See the License Key section.Enable
SAPIX_AUTH_ENABLED=true SAPIX_AUTH_JWT_EXPIRY_SECS=3600 # optional — default 1 hour SAPIX_AUTH_MAGIC_LINK_EXPIRY_SECS=900 # optional — default 15 min
The auth add-on stores users in the agent's GraphIndex meta column family and writes every authentication event to the strand as a signed nucleotide. There is no separate auth database.
Register & Login
// Register — creates a new user and returns a JWT
const res = await fetch("http://localhost:7475/v1/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "user@example.com", password: "s3cr3t!" }),
});
const { user_id, email, token } = await res.json();
// Login — verify password, return JWT
const login = await fetch("http://localhost:7475/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "user@example.com", password: "s3cr3t!" }),
});
const { token } = await login.json();
// All subsequent requests: attach the JWT
fetch("http://localhost:7475/v1/auth/me", {
headers: { Authorization: `Bearer ${token}` },
});import httpx
client = httpx.AsyncClient(base_url="http://localhost:7475")
# Register
r = await client.post("/v1/auth/register",
json={"email": "user@example.com", "password": "s3cr3t!"})
token = r.json()["token"]
# Verify current user
me = await client.get("/v1/auth/me",
headers={"Authorization": f"Bearer {token}"})
print(me.json()) # {"user_id": "usr_...", "email": "user@example.com"}Magic Links (Passwordless)
Magic links provide passwordless sign-in. A one-time token is generated and sent via the Mail add-on if configured, or returned directly for testing.
// Step 1: Request a magic link
// If SAPIX_MAIL_ENABLED=true → token is emailed automatically
// If mail not configured → token is returned in the response for testing
const req = await fetch("http://localhost:7475/v1/auth/magic-link/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "user@example.com" }),
});
const { sent, token: testToken } = await req.json();
// sent: true → email delivered (token not shown)
// sent: false → token: "a3f9..." (testing mode)
// Step 2: Verify the token from the link (single-use, deleted on verify)
const verify = await fetch("http://localhost:7475/v1/auth/magic-link/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: tokenFromEmail }),
});
const { token } = await verify.json(); // JWTJWT Format
JWTs use EdDSA (Ed25519). The signing key is derived deterministically from the agent's keypair seed via HKDF — no additional secret to manage, and the same key is produced on every restart. Verify tokens using the public key exposed at GET /v1/auth/jwks.
{
"sub": "usr_a3f9b2c1...",
"email": "user@example.com",
"app_metadata": { "role": "parent", "family_id": "fam_9f2a" },
"user_metadata":{ "display_name": "Alex" },
"iat": 1749427200,
"exp": 1749430800,
"jti": "3d8f2c1a..."
}Authorization: Bearer JWT is read by the same middleware that verifies it here, and its sub / email / app_metadata.* / user_metadata.* claims become $jwt.sub, $jwt.email, $jwt.app_metadata.<key>, $jwt.user_metadata.<key> placeholders in a row policy. A policy like { "field": "owner_id", "op": "eq", "value": "$jwt.sub" }is all it takes for "a user can only see their own rows" to be enforced by the database itself.Custom Claims — app_metadata & user_metadata
Every JWT carries two application-controlled claim objects that ride along with sub and email. Because they are embedded in the token, downstream services can read roles, tenant IDs, or display names without a database round-trip.
| Field | Who can write it | Typical use |
|---|---|---|
| app_metadata | Root API key only — PATCH /v1/auth/users/:email/app-metadata | Role, org_id, subscription tier, permissions — anything the application trusts |
| user_metadata | The user's own Bearer JWT — PATCH /v1/auth/me/user-metadata | Display name, preferences — self-service, lower trust |
PATCH /v1/auth/users/user@example.com/app-metadata
Authorization: Bearer spx_root_YOUR_ROOT_KEY
Content-Type: application/json
{ "metadata": { "role": "parent", "family_id": "fam_9f2a" } }
// Response
{ "email": "user@example.com", "app_metadata": { "role": "parent", "family_id": "fam_9f2a" } }PATCH /v1/auth/me/user-metadata
Authorization: Bearer <user_jwt>
Content-Type: application/json
{ "metadata": { "display_name": "Alex" } }
// Response
{ "email": "user@example.com", "user_metadata": { "display_name": "Alex" } }app_metadata or user_metadata.app_metadata (e.g. demoting a user from admin) does not invalidate existing tokens. The change takes effect when the user re-authenticates. Keep SAPIX_AUTH_JWT_EXPIRY_SECS short if role changes must propagate quickly.OAuth 2.0 — Social Login
SapixDB supports Google and GitHub as OAuth 2.0 providers. When a user authenticates via OAuth, SapixDB creates or finds their account by email and returns the same { token, refresh_token, email } response as other auth methods. OAuth accounts are automatically marked verified.
SAPIX_OAUTH_GOOGLE_CLIENT_ID=<from Google Console> SAPIX_OAUTH_GOOGLE_CLIENT_SECRET=<from Google Console> SAPIX_OAUTH_GITHUB_CLIENT_ID=<from GitHub OAuth app> SAPIX_OAUTH_GITHUB_CLIENT_SECRET=<from GitHub OAuth app> SAPIX_OAUTH_REDIRECT_BASE_URL=https://<sapixdb-railway-url> SAPIX_OAUTH_ALLOWED_REDIRECT_URIS=https://yourdomain.com,https://staging.yourdomain.com
Register {SAPIX_OAUTH_REDIRECT_BASE_URL}/v1/auth/oauth/google/callback as an authorized redirect URI in your Google Cloud OAuth app (same pattern for GitHub). SAPIX_OAUTH_ALLOWED_REDIRECT_URIS is required in production — without it, OAuth authorize requests return 400. For local development only, set SAPIX_OAUTH_ALLOW_ANY_REDIRECT_URI=true to skip the check (never in production).
Token delivery modes
The OAuth callback delivers tokens to your redirect_uri. Choose the mode that matches your architecture:
Point
redirect_uri at your backend, not the browser. Your backend receives the tokens in the query string, sets refresh_token as an httpOnly cookie, then redirects the browser with the short-lived token only. This matches how magic-link and password login already work and keeps refresh_token out of the browser address bar, history, and Referer headers.// Your backend route: GET /oauth/google/callback?token=...&refresh_token=...
app.get('/oauth/google/callback', (req, res) => {
const { token, refresh_token, email } = req.query;
res.cookie('refresh_token', refresh_token, {
httpOnly: true, secure: true, sameSite: 'lax', maxAge: 30 * 24 * 60 * 60 * 1000,
});
// Forward only the short-lived access token to the SPA
res.redirect(`https://yourapp.com/?token=${encodeURIComponent(token)}`);
});
// Start the flow — redirect_uri points at YOUR backend, not the browser
const startUrl = `${SAPIX_URL}/v1/auth/oauth/google/authorize` +
`?redirect_uri=https://api.yourapp.com/oauth/google/callback`;
window.location.href = startUrl;If you have no backend to receive the callback, add
redirect_mode=fragment to the authorize URL. Tokens are delivered after # instead of ? — URL fragments are never sent to any server, not logged by CDNs, and not included in Referer headers. Read them with window.location.hash in the browser. Do not combine fragment mode with a backend-hop — server-side request handlers never see fragment parameters.// Add redirect_mode=fragment to the authorize URL
const startUrl = `${SAPIX_URL}/v1/auth/oauth/google/authorize` +
`?redirect_uri=https://yourapp.com/callback&redirect_mode=fragment`;
window.location.href = startUrl;
// In your /callback page:
const hash = new URLSearchParams(window.location.hash.slice(1));
const token = hash.get('token');
const refreshToken = hash.get('refresh_token');Passkeys (WebAuthn)
SapixDB supports FIDO2 passkeys as a third authentication factor alongside passwords and magic-links. Passkeys use the WebAuthn standard — the browser or device authenticator (Touch ID, Face ID, Windows Hello, hardware security key) holds the private key and proves identity without a password ever leaving the device.
Passkeys return the same { token, refresh_token, user_id, email, verified } response shape as magic-link verify, so no changes are needed to the cookie-setting or session-management code.
Enable
Set these env vars on your SapixDB instance. Without them, passkey endpoints return 501 Not Implemented.
SAPIX_WEBAUTHN_RP_ID=yourdomain.com # effective domain of the frontend (no scheme, no port) SAPIX_WEBAUTHN_RP_NAME="Your App" # display name shown in browser passkey prompt SAPIX_WEBAUTHN_ORIGIN=https://yourdomain.com # full HTTPS origin of the frontend
Registration flow
// 1. Get a challenge (requires the user's auth JWT from a prior login/magic-link)
const { session_token, creation_options } = await fetch('/v1/auth/passkey/register-challenge', {
method: 'POST',
headers: { Authorization: `Bearer ${userJwt}` },
}).then(r => r.json());
// 2. Ask the browser to create a passkey
const credential = await navigator.credentials.create({ publicKey: creation_options });
// 3. Register the passkey
await fetch('/v1/auth/passkey/register', {
method: 'POST',
headers: { Authorization: `Bearer ${userJwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token, registration: credential }),
});
// → { "verified": true }Authentication flow
// 1. Get a challenge (email identifies which passkeys to use)
const { session_token, request_options } = await fetch('/v1/auth/passkey/authenticate-challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'user@example.com' }),
}).then(r => r.json());
// 2. Prompt the authenticator
const assertion = await navigator.credentials.get({ publicKey: request_options });
// 3. Authenticate
const { token, refresh_token } = await fetch('/v1/auth/passkey/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token, assertion }),
}).then(r => r.json());
// Store token in httpOnly cookie — same as magic-link verifyHTTP API Reference
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /v1/auth/register | — | Register new user, returns JWT + refresh_token |
| POST | /v1/auth/login | — | Password login, returns JWT + refresh_token |
| POST | /v1/auth/magic-link/send | — | Send or return magic link token |
| POST | /v1/auth/magic-link/verify | — | Exchange token for JWT + refresh_token |
| POST | /v1/auth/refresh | — | Exchange refresh_token for a new JWT (body: { refresh_token }) |
| POST | /v1/auth/logout | Bearer JWT | Delete the refresh_token, invalidating the session |
| POST | /v1/auth/verify-email | — | Verify email address via token (body: { token }) |
| POST | /v1/auth/revoke | Bearer JWT | Revoke the current JWT by its jti claim |
| GET | /v1/auth/status | — | Returns { enabled: bool } — useful for feature detection |
| GET | /v1/auth/me | Bearer JWT | Return current user + both metadata fields |
| PATCH | /v1/auth/me/user-metadata | Bearer JWT | Replace caller's own user_metadata |
| GET | /v1/auth/users | Root key | List all users |
| DELETE | /v1/auth/users/:email | Root key | Delete user (strand tombstone written) |
| PATCH | /v1/auth/users/:email/app-metadata | Root key only | Replace app_metadata for a user |
| GET | /v1/auth/jwks | — | JWT algorithm metadata (EdDSA public key) |
| GET | /v1/auth/oauth/:provider/authorize | — | Start OAuth flow. Optional: ?redirect_mode=fragment for pure-SPA delivery |
| GET | /v1/auth/oauth/:provider/callback | — | OAuth callback — internal, used by provider |
| POST | /v1/auth/passkey/register-challenge | Bearer JWT | Start passkey registration, returns creation_options |
| POST | /v1/auth/passkey/register | Bearer JWT | Finish passkey registration |
| POST | /v1/auth/passkey/authenticate-challenge | — | Start passkey auth (body: { email }), returns request_options |
| POST | /v1/auth/passkey/authenticate | — | Finish passkey auth, returns JWT pair |
Strand audit records
Every auth event is written to the strand as a signed nucleotide — the same cryptographic guarantee as any other record. This makes auth history tamper-evident and exportable via GET /v1/strand/export for compliance audits.
| Strand record type | Trigger |
|---|---|
| auth/register | New user registered |
| auth/login | Successful password login |
| auth/magic_link_sent | Magic link requested |
| auth/user_deleted | User deleted via admin endpoint |
Configuration
| Environment variable | Default | Description |
|---|---|---|
| SAPIX_AUTH_ENABLED | false | Enable the auth add-on |
| SAPIX_AUTH_JWT_EXPIRY_SECS | 3600 | JWT lifetime in seconds (1 hour) |
| SAPIX_AUTH_MAGIC_LINK_EXPIRY_SECS | 900 | Magic link token lifetime (15 min) |
| SAPIX_AUTH_REQUIRE_EMAIL_VERIFY | false | Require email verification before login is permitted |
| SAPIX_AUTH_REFRESH_TOKEN_EXPIRY_SECS | 2592000 | Refresh token lifetime (30 days) |
| SAPIX_AUTH_MAX_LOGIN_ATTEMPTS | 5 | Failed login attempts before account lockout |
| SAPIX_AUTH_LOCKOUT_DURATION_SECS | 900 | Lockout duration after max failures (15 min) |
| SAPIX_AUTH_METADATA_MAX_BYTES | 4096 | Combined size limit for app_metadata + user_metadata per user |
| SAPIX_OAUTH_GOOGLE_CLIENT_ID | — | Google OAuth 2.0 client ID |
| SAPIX_OAUTH_GOOGLE_CLIENT_SECRET | — | Google OAuth 2.0 client secret |
| SAPIX_OAUTH_GITHUB_CLIENT_ID | — | GitHub OAuth app client ID |
| SAPIX_OAUTH_GITHUB_CLIENT_SECRET | — | GitHub OAuth app client secret |
| SAPIX_OAUTH_REDIRECT_BASE_URL | — | Public base URL of this SapixDB instance (used to construct OAuth callback URLs) |
| SAPIX_OAUTH_ALLOWED_REDIRECT_URIS | — | Comma-separated allowed redirect_uri prefixes. Required in production — OAuth authorize returns 400 when unset. |
| SAPIX_OAUTH_ALLOW_ANY_REDIRECT_URI | false | Dev only: skip allowed-URI check and warn instead of blocking. Never set in production. |
| SAPIX_WEBAUTHN_RP_ID | — | Relying Party ID for passkeys (effective domain, no scheme/port). Required to enable passkey endpoints. |
| SAPIX_WEBAUTHN_RP_NAME | SapixDB | Display name shown in browser passkey prompt |
| SAPIX_WEBAUTHN_ORIGIN | — | Full HTTPS origin of the frontend (e.g. https://yourdomain.com). Required to enable passkey endpoints. |
| SAPIX_RATE_LIMIT_RPS | 100 | Global IP rate limit in requests/second. Applied to all routes before API-key auth. |
| SAPIX_RATE_LIMIT_BURST | 200 | Max token capacity for global IP rate limit. New IPs start at rps tokens (not burst) to limit VPN-rotating attacks. |
| SAPIX_AUTH_RATE_LIMIT_RPS | 10 | Stricter IP rate limit on /v1/auth/ routes (requests/second per IP). |
| SAPIX_AUTH_RATE_LIMIT_BURST | 20 | Max token capacity for auth-route IP rate limit. New IPs start at auth_rps tokens, not burst. |
Organism-Scoped Auth Namespaces
Everything above describes one flat, process-wide user table and one JWT signing key — fine for a single application. A platform hosting many independent applications on one SapixDB deployment (each needing its own users, its own signing key, fully walled off from every other tenant) can instead give each organism — a group of agents created together under one organism_id via POST /v1/organisms — its own dedicated auth namespace, no extra process, no extra infrastructure.
curl -X POST http://localhost:7475/v1/organisms/acme/auth/init \
-H "Authorization: Bearer $ROOT_KEY"
// → 201 { "organism_id": "acme", "agent_id": "acme::auth", "created_at_ms": ... }This creates an acme::auth sub-agent — the same sibling-agent mechanism POST /v1/organisms/:id/agents uses for ordinary agents — and binds a dedicated AuthStore to it: its own user table, its own deterministically-derived JWT signing key, its own lockout/refresh/revocation state, genuinely isolated storage from both the primary agent and every other namespace. Requires the master seed (same requirement as any other organism agent) and the auth add-on enabled.
Every route above gets an identical organism-scoped mirror: POST /v1/organisms/:id/auth/register, login, refresh, logout, me, magic-link send/verify, jwks, OAuth authorize/callback, and passkey register/authenticate — same request/response shapes, just resolved against that organism's own AuthStore instead of the process-wide one. The admin routes (users list/delete, app-metadata) require admin:organisms / admin:* — the same scope every other organism-management operation already needs, not a new per-organism tier.
auth:lockout:<email> into its own separate storage, so a lockout in one namespace never touches the same email address in another.A few things stay process-wide by design, not by omission: OAuth provider app credentials (one Google/GitHub app shared across every namespace — only the callback URL differs per organism, so a new organism wanting OAuth needs one more redirect URI registered with the provider), WebAuthn RP config (one deployment has one browser origin, so one RP identity is actually correct here), and the auth add-on's config tunables (JWT/refresh/lockout durations — every namespace inherits the same process-wide values, no per-namespace override yet).
Known Limitations
- No token revocation. JWTs are stateless — a stolen token is valid until expiry. Keep
SAPIX_AUTH_JWT_EXPIRY_SECSshort. - app_metadata changes don't take effect until token renewal. Existing tokens carry the old values until they expire and the user re-authenticates.
- Single agent scope. Users registered on one agent are not shared with peer mesh agents. Run a dedicated auth agent if auth must span a cluster. For per-organism isolation on a single deployment, see Organism-Scoped Auth Namespaces above instead — no separate agent/process needed.
- OAuth requires an allowed-redirect list in production. Set
SAPIX_OAUTH_ALLOWED_REDIRECT_URIS; without it OAuth authorize returns 400. For local dev only, setSAPIX_OAUTH_ALLOW_ANY_REDIRECT_URI=trueto restore the old warn-and-allow behaviour — never use this in production. - Passkeys require explicit configuration. Set
SAPIX_WEBAUTHN_RP_IDandSAPIX_WEBAUTHN_ORIGIN; without them passkey endpoints return 501. The RP ID must be the bare domain (nohttps://, no port). - No SAML / enterprise SSO. OAuth (Google + GitHub) covers most federation cases; SAML/SCIM is deferred.
- IP rate limiting is outermost but not a WAF replacement. The built-in token bucket (default: 100 rps global / 10 rps for auth routes) protects against basic flooding. For DDoS-scale traffic, put a CDN or WAF in front.
- Magic links are single-use. If the link is lost, request a new one.