SapixDBSapixDB/Docs
Home
Add-on

Auth Add-on

✓ Shipped

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.

🔑 One env var to activate: SAPIX_AUTH_ENABLED=true
Included 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

docker-compose.yml / Railway env
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

TypeScript
// 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}` },
});
Python
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 provide passwordless sign-in. A one-time token is generated and sent via the Mail add-on if configured, or returned directly for testing.

TypeScript
// 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(); // JWT
How magic links work: A 32-byte random token is generated. Only its SHA-256 hash is stored in the agent — the raw token only travels via email or the API response. On verification the hash is recomputed, the expiry is checked, and the stored entry is deleted. One-time, tamper-evident, secure.

JWT 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.

JWT claims
{
  "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..."
}
These claims drive row-level data isolation. Every request's 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.

FieldWho can write itTypical use
app_metadataRoot API key only — PATCH /v1/auth/users/:email/app-metadataRole, org_id, subscription tier, permissions — anything the application trusts
user_metadataThe user's own Bearer JWT — PATCH /v1/auth/me/user-metadataDisplay name, preferences — self-service, lower trust
Set app_metadata (root key)
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" } }
Set user_metadata (bearer JWT)
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" } }
Metadata is signed, not encrypted
Any holder of the JWT can base64-decode and read both fields. Never store secrets, passwords, or sensitive PII in app_metadata or user_metadata.
Role changes take effect on next token
JWTs are stateless. Updating 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.

env vars
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:

Recommended: backend-hop (query string, default)
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.
Backend-hop pattern (Node/Express example)
// 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;
Pure-SPA (no backend): use redirect_mode=fragment
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.
Pure-SPA fragment mode
// 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 verify

HTTP API Reference

MethodPathAuthDescription
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/logoutBearer JWTDelete the refresh_token, invalidating the session
POST/v1/auth/verify-email—Verify email address via token (body: { token })
POST/v1/auth/revokeBearer JWTRevoke the current JWT by its jti claim
GET/v1/auth/status—Returns { enabled: bool } — useful for feature detection
GET/v1/auth/meBearer JWTReturn current user + both metadata fields
PATCH/v1/auth/me/user-metadataBearer JWTReplace caller's own user_metadata
GET/v1/auth/usersRoot keyList all users
DELETE/v1/auth/users/:emailRoot keyDelete user (strand tombstone written)
PATCH/v1/auth/users/:email/app-metadataRoot key onlyReplace 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-challengeBearer JWTStart passkey registration, returns creation_options
POST/v1/auth/passkey/registerBearer JWTFinish 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 typeTrigger
auth/registerNew user registered
auth/loginSuccessful password login
auth/magic_link_sentMagic link requested
auth/user_deletedUser deleted via admin endpoint

Configuration

Environment variableDefaultDescription
SAPIX_AUTH_ENABLEDfalseEnable the auth add-on
SAPIX_AUTH_JWT_EXPIRY_SECS3600JWT lifetime in seconds (1 hour)
SAPIX_AUTH_MAGIC_LINK_EXPIRY_SECS900Magic link token lifetime (15 min)
SAPIX_AUTH_REQUIRE_EMAIL_VERIFYfalseRequire email verification before login is permitted
SAPIX_AUTH_REFRESH_TOKEN_EXPIRY_SECS2592000Refresh token lifetime (30 days)
SAPIX_AUTH_MAX_LOGIN_ATTEMPTS5Failed login attempts before account lockout
SAPIX_AUTH_LOCKOUT_DURATION_SECS900Lockout duration after max failures (15 min)
SAPIX_AUTH_METADATA_MAX_BYTES4096Combined 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_URIfalseDev 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_NAMESapixDBDisplay 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_RPS100Global IP rate limit in requests/second. Applied to all routes before API-key auth.
SAPIX_RATE_LIMIT_BURST200Max token capacity for global IP rate limit. New IPs start at rps tokens (not burst) to limit VPN-rotating attacks.
SAPIX_AUTH_RATE_LIMIT_RPS10Stricter IP rate limit on /v1/auth/ routes (requests/second per IP).
SAPIX_AUTH_RATE_LIMIT_BURST20Max 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.

POST /v1/organisms/:id/auth/init
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.

Cross-namespace isolation is structural, not a policy check.A JWT issued under one namespace fails signature verification against every other namespace's key — there's no shared secret for a token to accidentally match. Brute-force lockout state is likewise isolated for free: each namespace writes 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_SECS short.
  • 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, set SAPIX_OAUTH_ALLOW_ANY_REDIRECT_URI=true to restore the old warn-and-allow behaviour — never use this in production.
  • Passkeys require explicit configuration. Set SAPIX_WEBAUTHN_RP_ID and SAPIX_WEBAUTHN_ORIGIN; without them passkey endpoints return 501. The RP ID must be the bare domain (no https://, 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.
→ Row-Level Policies→ Mail Add-on→ Chat Add-on→ API Keys & Security