Authentication
End-user auth with anonymous sessions, Apple / Google social login, email-password, and account linking — all backed by Amba-native JWTs.
Amba provides a complete end-user auth system for mobile apps. Every user starts with an anonymous identity stored client-side; they can later sign in with Apple, Google, or email / password, and Amba preserves their anonymous history through account linking.
Credentials live in your project's database (physically isolated from every other Amba project) and passwords are hashed with bcrypt — no DIY crypto, no third-party auth vendor to get locked into.
How it works
- On first launch,
init()generates a localanonymous_idand persists it to storage. - When the user signs in (Apple, Google, or email), the SDK exchanges a credential for a session token (short-lived JWT) + refresh token (90 days). Both are persisted.
- The SDK attaches
Authorization: Bearer <session_token>to every user-scoped request. - When the session expires, the SDK rotates via
POST /client/auth/refresh(rotating both tokens and revoking the old refresh token's server-side record). logout()revokes the refresh token server-side and clears local state.
JWT claims
Session tokens carry:
| Claim | Meaning |
|---|---|
sub | app_users.id |
pid | projectId — defense in depth: the API rejects a token that doesn't match the API key's project |
anon | the original anonymous_id |
Refresh tokens additionally carry a sid (session id) that maps to a server-side app_user_sessions row. Rotation revokes the old row and mints a new one, so a replayed refresh token is detected.
Display names
Every app_users row has a non-null display_name. If the caller doesn't supply one at signup time, the API generates an anonymized "OakHiker" / "DustOwl" / "RiverWalker"-style name from a 32-adjective × 32-noun wordlist (1024 combinations, source: apps/api/src/lib/display-name.ts).
This applies to every signup path that creates a new app_users row:
| Path | Behaviour |
|---|---|
POST /client/auth/anonymous | Always generates — there's no name to take from the caller. |
POST /client/auth/social | Uses the provider's name claim if present (Google), generates if absent (Apple withholds name on subsequent sign-ins). |
POST /client/auth/email/signup | Uses body.display_name if supplied, generates otherwise. |
POST /client/auth/magic-link/verify | Always generates on the new-user branch — magic-link has no name field. |
The wordlist skews nature / outdoors / friendly so leaderboards, friends lists, and "Sam (you)"-style social UI work on every project without domain tuning. Words are picked with Math.random — these names are not cryptographic identifiers (the user's id and anonymous_id columns are). Collisions inside a single project are not enforced; expect ~50% collision rate past ~38 users (birthday-paradox math on 1024 buckets).
Overriding the generated name
The user can change their display name at any time via PATCH /client/users/me:
For email signup, you can supply display_name directly in the request body so the user is never briefly labelled OakHiker in the UI:
SDK usage
The @layers/amba-client auth module signatures:
Anonymous identity (automatic)
Email signup / login
Passwords are hashed server-side with bcrypt at cost factor 10. The plaintext password is never stored and never returned to the client.
Email magic-link
Passwordless email sign-in. The user enters their email, gets a one-tap link in their inbox, and is signed in (or signed up) on click — no password to remember, nothing to mint a user-side via the admin API.
The flow is two endpoints:
Tokens are 32 bytes (base64url, ~256 bits of entropy), expire in 15 minutes, and are single-use — a verify call marks used_at atomically so a replayed link returns 401 INVALID_TOKEN. Only the SHA-256 of the token lives in the DB; the raw token only ever exists in the user's mailbox.
If the email doesn't yet have an app_users row, verify creates one (with the email pre-verified). If it already does — for example because the user previously did /email/signup — verify links the same row, so a user's history is preserved across passwordless and password-based sign-ins.
The /request endpoint is rate-limited per email (5/hour, 20/day) and per IP (10/min, 200/day). Re-requesting because the first email got lost is fine; spamming someone's inbox isn't.
The redirect origin is taken from the request's Origin header, with MAGIC_LINK_REDIRECT_BASE_URL as an explicit override and https://app.amba.dev as the final fallback. amba handles email delivery and template rendering — you don't configure or wire up an email provider.
Apple Sign In
The server verifies the token's signature + issuer against Apple's JWKS before trusting any claims.
Google Sign In
Same JWKS verification on the server.
Account linking
Preserve a user's anonymous history when they later add a social login:
Email linking goes through the verified signUpWithEmail / loginWithEmail flow, not linkAccount.
Session management
Session shape:
Token refresh
The SDK persists refreshToken on persistSession(). Apps typically never call refresh manually — the API retries with exponential back-off, and a stale session token should be swapped for a refreshed one via POST /client/auth/refresh { refresh_token }.
If you need to force-rotate (e.g. after a permissions change on the server), call client.auth.refresh():
Current user
client.auth.me() fetches /client/users/me using the current session token and returns the AppUser record:
Common AmbaApiError.code values on auth
| Code | When |
|---|---|
INVALID_CREDENTIALS | Wrong email/password on loginWithEmail. |
USER_EXISTS | Email already registered on signUpWithEmail. |
INVALID_EMAIL | Malformed email. |
WEAK_PASSWORD | Password doesn't meet policy. |
RATE_LIMITED | Too many attempts from this IP / user. |
INVALID_TOKEN | Apple/Google token failed signature verification. |
Switch on err.code rather than err.message — messages are human-readable and may change.
Expo one-liners
@layers/amba-expo wraps the native sign-in flows so you don't have to touch Apple / Google SDKs directly:
For email auth just use Amba.auth.signUpWithEmail(...) / Amba.auth.loginWithEmail(...) — the auth accessor is passed through to the underlying AmbaClient.
Routes reference
| Method | Path | Description |
|---|---|---|
POST | /client/auth/anonymous | Create a new anonymous user + session |
POST | /client/auth/social | Exchange an Apple or Google identity token for an Amba session |
POST | /client/auth/email/signup | Email signup (bcrypt-hashed password) |
POST | /client/auth/email/login | Email login (verifies bcrypt hash) |
POST | /client/auth/magic-link/request | Send a one-tap sign-in link to the given email |
POST | /client/auth/magic-link/verify | Exchange a magic-link token for a session |
POST | /client/auth/link | Link the current anonymous session to a social provider |
POST | /client/auth/refresh | Rotate session + refresh tokens |
POST | /client/auth/logout | Revoke the refresh token |
All client auth routes require X-Api-Key. /link, /refresh, and /logout additionally require the current session / refresh token in the body.
Database tables
| Table | Purpose |
|---|---|
app_users | Core user record: id, email, anonymous_id, display_name, auth_providers (JSONB), properties (JSONB), password_hash, timestamps |
account_links | Audit log of anonymous → authenticated transitions |
app_user_sessions | Server-side refresh-token records. Each carries a sha256 hash of the refresh token, expires_at, revoked_at, user_agent, and ip |
magic_link_tokens | Pending magic-link sign-in tokens: email, token_hash (sha256), expires_at, used_at, optional app_user_id link |