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 | the user's 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 session record. Rotation revokes the old session and mints a new one, so a replayed refresh token is detected.
Display names
Every user has a non-null display_name. If the caller doesn't supply one at signup, Amba generates a friendly nature-themed name like "OakHiker", "DustOwl", or "RiverWalker" from a curated wordlist.
This applies to every signup path that creates a new user:
| 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 friendly and approachable so leaderboards, friends lists, and "Sam (you)"-style social UI work on every project without domain tuning. Display names aren't unique — expect duplicates as a project grows past a few dozen users. Users can change theirs at any time (see below).
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 Amba auth module signatures (same shape in @layers/amba-web, @layers/amba-node, @layers/amba-expo, and @layers/amba-react-native):
Anonymous identity (automatic)
configure() builds local state but does not, by itself, create a server session. By default the
SDK signs in anonymously on the first authenticated call, so reads work out of the box. Pass
autoAnonymousSession: false to configure() to require an explicit signInAnonymously() (or
another sign-in) before any authenticated call.
Email sign-up and sign-in
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 map to a user, verify creates one (with the email pre-verified). If it does — for example because the user previously did /email/signup — verify links the same user, so 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 sends the magic-link email for you — there's no email provider to configure.
Email one-time passcode (OTP)
Sibling to magic-link, different UX: the user types a 6-digit code into the app instead of clicking a link in their inbox. Better fit for mobile, games, embedded webviews, and cross-device flows (read the code on a laptop, type it into your phone app).
Codes are 6 ASCII digits, expire in 10 minutes, and are single-use. The verify call rate-limits attempts to 5 per code — after 5 misses the challenge is invalidated, and the user has to request a fresh code. Every failure mode (wrong code, expired, exhausted, no challenge) returns the same 400 INVALID_CODE envelope so an attacker can't distinguish them.
Requesting a second code for the same email invalidates the prior unused one, so only the latest code is ever live.
Like magic-link, only sha256-hashes live in the DB — the raw code only ever exists in the user's mailbox until verify or expiry. If the email doesn't yet map to a user, verify creates one with the email pre-verified.
The /request endpoint is rate-limited per email (5/hour, 20/day) and per IP (10/min, 200/day). Amba sends the OTP email for you — there's no email provider to configure.
SMS one-time passcode (OTP)
Sibling to email-OTP, phone-based. Use when phone is the user's primary identity (rideshare drivers, delivery couriers, marketplaces), or when email deliverability is unreliable for your audience.
Phone must be E.164 (starts with +, total 8–15 digits, no spaces or dashes). The SDK rejects non-conforming phones before the network call. Codes are 6 ASCII digits, expire in 10 minutes, single-use. Verify returns a single INVALID_CODE error for wrong/expired/exhausted so failure cases can't be distinguished by an attacker.
The SMS body includes a WebOTP-style appstring (@amba.host #<code>) iOS/Android use to surface "Tap to copy" / auto-fill UX in the OTP input.
Per-phone rate limits scale with project tier:
| Tier | per phone per hour | per phone per day |
|---|---|---|
| Free | 3 | 10 |
| Pro | 10 | 50 |
| Scale | 50 | 500 |
| Enterprise | — (no cap) | — |
Per-IP caps stay constant across tiers (10/min, 200/day) as abuse defense.
Sign in with Apple
One-time project setup. Apple Sign In identity tokens carry your iOS bundle id in the aud claim. Tell Amba which bundle id to expect:
Without this, signInWithApple returns 400 AUDIENCE_NOT_CONFIGURED.
Runtime call:
The server verifies the token's signature against Apple's public JWKS, then checks aud == bundle_id. No customer-side secrets — the bundle id is a public identifier (it ships in your app binary).
Sign in with Google
One-time project setup. Google id tokens carry your OAuth 2.0 client id in the aud claim. Set the expected client id on your project:
The client id is the same value you embed in your app's native Google Sign In config (expo-auth-session's iosClientId / androidClientId, or @react-native-google-signin/google-signin's webClientId). It's a public identifier — never a secret. Without it set, signInWithGoogle returns 400 AUDIENCE_NOT_CONFIGURED.
Runtime call:
Same JWKS verification on the server (aud == google_oauth_client_id).
Account linking
Preserve a user's anonymous history (codename, holds, streak, collections) when they later add an identifier. All identifier types share the same contract:
- Same
user_idbefore and after. No merge, no new row. - Existing session token stays valid. No forced rotation, no auth-state-change fired.
- Idempotent. Linking the same identifier to the same user a second time succeeds without error.
- Typed collision error. If the identifier is already verified to a different user, the link is refused with a
*_ALREADY_LINKEDerror and the conflictinguser_idinerror.details.conflicting_user_id— the SDK consumer decides whether to switch (call the matchingverify*instead) or abort.
Social (Apple, Google)
Phone via SMS OTP
requestSmsOtp doesn't commit the caller to verify-vs-link — that's a client-side choice made when the code arrives.
On collision the SDK throws an AmbaApiError with code === 'PHONE_ALREADY_LINKED' and details.conflicting_user_id:
Email via OTP
Symmetric to SMS:
The pre-OTP linkAccount('email', ...) path is permanently refused — there was no verification, so anyone could have claimed any email. Use linkEmailOtp (OTP-verified) instead.
Email + password
Upgrade the current (typically anonymous) session to an email-identified account by attaching an email + password credential — the guest's data (streak, library, progress) carries over because it's the same user converted in place, not a new row. No code round-trip: the password itself is the credential.
On collision the SDK throws AmbaApiError with code === 'EMAIL_ALREADY_LINKED' and
details.conflicting_user_id (the email is already owned by another user) — decide
whether to sign in to that account instead or abort.
Inspecting + removing identifiers
Rate-limit headers
Every /sms-otp/* and /email-otp/* response carries:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Max requests in the most restrictive window |
X-RateLimit-Remaining | Requests left in that window |
X-RateLimit-Reset | Seconds until that window rolls over (delta-seconds, same shape as Retry-After) |
These appear on success AND on 429, so an app can render a "wait 30s" hint without sniffing error bodies.
Session management
Session shape:
Token refresh
The SDK persists refreshToken automatically. Apps typically never call refresh manually — when a session token expires, the SDK rotates both tokens transparently.
If you need to force-rotate (e.g. after a permissions change on the server), call Amba.auth.refresh():
Current user
Amba.auth.me() fetches /client/users/me using the current session token and returns the AppUser record:
Error handling with AmbaApiError
Every Amba SDK call rejects with an AmbaApiError on failure. Narrow with instanceof and branch on the stable .code field rather than parsing .message:
The same AmbaApiError class is exported from @layers/amba-web, @layers/amba-react-native, and @layers/amba-expo. Code that handles errors uniformly across the web and Expo halves of an app can import from either and the instanceof check matches.
Common AmbaApiError.code values on auth
| Code | When |
|---|---|
INVALID_CREDENTIALS | Wrong email/password on signInWithEmail. |
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. The Expo wrappers call Amba.auth.signInWithApple(identityToken) / Amba.auth.signInWithGoogle(idToken) under the hood once the native prompt returns:
For email auth, call Amba.auth.signUpWithEmail(...) and Amba.auth.signInWithEmail(...) directly — they're available on every Amba SDK.
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 sign-in (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/email-otp/request | Send a 6-digit one-time passcode to the given email |
POST | /client/auth/email-otp/verify | Exchange (email, code) for a session |
POST | /client/auth/sms-otp/request | Send a 6-digit one-time passcode by SMS to the given E.164 phone |
POST | /client/auth/sms-otp/verify | Exchange (phone, code) for a session |
POST | /client/auth/link | Link an identifier (apple, google, sms_otp, email_otp) to current session |
POST | /client/auth/unlink | Detach an identifier from the current session's user |
GET | /client/auth/identifiers | List identifier providers attached to current user (masked) |
POST | /client/auth/refresh | Rotate session + refresh tokens |
POST | /client/auth/logout | Sign out — revoke the refresh token |
All client auth routes require X-Api-Key. /link, /unlink, /refresh, and /logout additionally require the current session / refresh token in the body; /identifiers requires the session token in Authorization: Bearer.