User Auth — Register, Login & Magic Links
Add end-user authentication to your app: password registration, login, passwordless magic-link sign-in, JWT access tokens, and refresh token rotation.
What you'll learn
- ✓SAPIX_AUTH_ENABLED=true — enable the auth add-on (included in enterprise binary, no recompile needed)
- ✓GET /v1/auth/status — returns { enabled: bool }, safe to call without an API key (feature probe)
- ✓POST /v1/auth/register — email + password, returns JWT + refresh_token
- ✓POST /v1/auth/login — password auth, same response shape
- ✓POST /v1/auth/magic-link/send + /verify — passwordless email sign-in
- ✓JWT is EdDSA (Ed25519), verified via GET /v1/auth/jwks — verify without contacting SapixDB
- ✓POST /v1/auth/refresh — exchange refresh_token for a new JWT (30-day refresh window by default)
- ✓POST /v1/auth/logout — delete refresh_token; POST /v1/auth/revoke — revoke JWT by jti
- ✓Per-email brute-force lockout (5 failures → 15-min lock, configurable)
- ✓GET /v1/auth/me — decode the current user from their Bearer JWT
Register a user, log in, call GET /v1/auth/me. Expire the JWT by setting SAPIX_AUTH_JWT_EXPIRY_SECS=1, then call refresh and confirm you get a new valid JWT.
## User Auth — Register, Login & Magic Links
Lessons 34–35 covered machine-to-machine API keys. This lesson covers the User Auth add-on — a full end-user authentication system built directly into sapix-agent with no external auth service required.
> Prerequisite: Lesson 34 (API keys & scopes). This add-on runs alongside the API-key layer, not instead of it.
---
## Enable
SAPIX_AUTH_ENABLED=true
SAPIX_AUTH_JWT_SECRET=<32-byte hex> # for JWT signing (EdDSA)
SAPIX_AUTH_MAGIC_LINK_TTL_SECS=900 # magic link expiry, default 15 min
SAPIX_AUTH_RESEND_API_KEY=re_... # required for magic-link email delivery
SAPIX_AUTH_FROM_EMAIL=noreply@yourdomain.comUser records are stored on the agent's own strand — the same append-only, cryptographically signed chain as all other data.
---
## Registration
curl -X POST http://localhost:7475/v1/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "alice@example.com",
"password": "correct-horse-battery"
}'Response:
`json
{ "user_id": "usr_a3f9...", "email": "alice@example.com", "created_at": "2026-08-19T..." }
`
Passwords are hashed with Argon2id before storage. The raw password never appears in any strand record.
---
## Login (password)
curl -X POST http://localhost:7475/v1/auth/login \
-H "Content-Type: application/json" \
-d '{ "email": "alice@example.com", "password": "correct-horse-battery" }'Response:
`json
{
"access_token": "eyJ...",
"refresh_token": "spx_rt_...",
"expires_in": 3600
}
`
The access_token is a short-lived EdDSA JWT (1 hour default). The refresh_token is a long-lived opaque token stored on the strand — revocable at any time.
---
## JWT structure
SapixDB issues EdDSA (Ed25519) JWTs — the same keypair that signs strand records also signs your user tokens.
{
"sub": "usr_a3f9...",
"email": "alice@example.com",
"app_metadata": { "role": "admin" },
"user_metadata": { "display_name": "Alice" },
"iat": 1755648000,
"exp": 1755651600
}Verify the public key at:
`
GET /v1/auth/jwks
`
This returns a standard JWKS document — compatible with any JWT library that supports JWKS key discovery.
---
## Get the current user
curl http://localhost:7475/v1/auth/me \
-H "Authorization: Bearer eyJ..."Response:
`json
{
"user_id": "usr_a3f9...",
"email": "alice@example.com",
"app_metadata": {},
"user_metadata": {},
"created_at": "2026-08-19T..."
}
`
---
## Refresh a JWT
Access tokens expire in 1 hour. Use the refresh token to get a new one without forcing the user to log in again:
curl -X POST http://localhost:7475/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{ "refresh_token": "spx_rt_..." }'Response: same structure as login — new access_token + new refresh_token.
The old refresh token is immediately invalidated (token rotation).
---
## Logout & token revocation
`bash
# Revoke the current refresh token (logout from this device)
curl -X POST http://localhost:7475/v1/auth/logout \
-H "Authorization: Bearer eyJ..." \
-H "Content-Type: application/json" \
-d '{ "refresh_token": "spx_rt_..." }'
# Revoke ALL refresh tokens for this user (logout from all devices)
curl -X POST http://localhost:7475/v1/auth/revoke-all \
-H "Authorization: Bearer eyJ..."
`
Revocation writes a tombstone on the strand — the token is gone even after a server restart.
---
## Magic links (passwordless)
Step 1 — Request a magic link:
`bash
curl -X POST http://localhost:7475/v1/auth/magic-link/send \
-H "Content-Type: application/json" \
-d '{ "email": "alice@example.com" }'
`
SapixDB generates a one-time token, stores it (TTL: 15 min), and sends an email via Resend with a link like:
https://yourapp.com/auth/callback?token=otp_abc123
Step 2 — Your frontend exchanges the token:
`bash
curl -X POST http://localhost:7475/v1/auth/magic-link/verify \
-H "Content-Type: application/json" \
-d '{ "token": "otp_abc123" }'
`
Response: same as login — access_token + refresh_token. The one-time token is invalidated immediately after use.
---
## Per-email brute-force lockout
After 5 consecutive failed login attempts for the same email address, that account is locked for 15 minutes. The lockout is stored on the strand — Alabay monitors it and will emit an alert finding if lockouts exceed a threshold.
The global IP rate limit (Lesson 35) handles cross-email scanning at the network layer.
---
## Full endpoint reference
| Method | Path | Description |
|---|---|---|
| GET | /v1/auth/status | Returns { enabled: bool } — no API key required |
| POST | /v1/auth/register | Create a new user account |
| POST | /v1/auth/login | Password login → JWT + refresh token |
| GET | /v1/auth/me | Current user profile (requires JWT) |
| POST | /v1/auth/refresh | Exchange refresh token for new JWT |
| POST | /v1/auth/logout | Revoke one refresh token |
| POST | /v1/auth/revoke-all | Revoke all refresh tokens for this user |
| POST | /v1/auth/magic-link/send | Send a passwordless magic link |
| POST | /v1/auth/magic-link/verify | Verify magic link token → JWT |
| GET | /v1/auth/jwks | Ed25519 public key in JWKS format |
---
## TypeScript: auth client
`typescript
const BASE = 'http://localhost:7475';
async function login(email: string, password: string) {
const res = await fetch(${BASE}/v1/auth/login, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error(await res.text());
return res.json() as Promise<{ access_token: string; refresh_token: string; expires_in: number }>;
}
async function getMe(accessToken: string) {
const res = await fetch(${BASE}/v1/auth/me, {
headers: { Authorization: Bearer ${accessToken} },
});
return res.json();
}
async function refresh(refreshToken: string) {
const res = await fetch(${BASE}/v1/auth/refresh, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
return res.json();
}
`
---
## Challenge
- Register a new user at
/v1/auth/register. - Log in with
/v1/auth/loginand save both tokens. - Call
/v1/auth/mewith the access token — confirm your email and user_id appear. - Call
/v1/auth/refreshto rotate the refresh token — verify you receive a new access token. - Call
/v1/auth/logoutto revoke the refresh token, then try/v1/auth/refreshagain — confirm it returns401.
---
See also: Lesson 34 (API keys), Lesson 35 (global IP rate limiting), Lesson 59 (OAuth, passkeys, and custom claims).









Sensart Technologies