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. While the user is purely anonymous (no email, phone, password, or linked Apple / Google identity), that id signs them back in, so the SDKs store it with the session tokens. Once the user has any of those, they sign in with it, and the anonymous id no longer signs in to their account. - 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.
A password sign-up signs the user in right away, and the address stays unverified
(email_verified: false) until someone proves they read that inbox. That matters
when two people use one address: anyone can type someone else's email into a sign-up
form. Amba treats the inbox owner as the account owner:
- Verify from the signed-in session to keep the password. Send a code with
Amba.auth.requestEmailOtp(email)and confirm it withAmba.auth.linkEmailOtp(email, code)while the password session is active. The address becomes verified, and the password and every session keep working. - An inbox sign-in from anywhere else takes the account over. When the address is
verified by signing in (
verifyEmailOtp, a magic link, or an Apple / Google sign-in whose provider verified that email) while the account still holds an unverified password, Amba clears that password, signs out every other session, and detaches the phone, Apple / Google links, and anonymous id that were attached before the inbox was proven. The person who signed in keeps the account and its data, and can set a new password withlinkEmailPassword. From then on the account signs in by its verified email (and any method linked again); an anonymous id no longer resumes it. - After a takeover, re-link Apple / Google from a signed-in session. A Sign in with
Apple / Google whose email is the account's address fails until it is linked again
with
linkAccount; one whose email differs (an Apple private-relay address, another Google address) creates a separate, empty account.
An account that never held an unverified password is never affected: email OTP, magic links, and Apple / Google sign-in behave exactly as they always have.
Password sign-in is budgeted before the password is checked: 20 attempts a minute and
200 an hour per IP, 10 per 15 minutes and 100 a day per account, and a per-app ceiling
(60 a minute on free projects, 600 on paid ones). Past a budget, signInWithEmail
fails with 429 RATE_LIMIT_EXCEEDED and a Retry-After header. A person who mistypes
a few times stays well inside the account budget.
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 signs in that same user, so history is preserved across passwordless and password-based sign-ins. When that account's password was set before its inbox was verified, the click also clears the password and signs out every other session (see Email sign-up and sign-in).
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.
Set the exact callback and email brand once through the console's Auth page, amba_projects_update, or the admin API:
Amba appends ?token=... to that exact callback. If the project callback is unset, the legacy fallback uses MAGIC_LINK_REDIRECT_BASE_URL, then an allowlisted request Origin, then https://app.amba.dev. Amba sends the magic-link email for you. Set a verified sending domain and project default sender on the Email page to use your own mailbox for magic links and OTP codes. Without one, the project brand appears in sender attribution (<brand> via Amba) on Amba's shared mailbox.
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).
Sign-in nonce (replay protection)
A nonce binds an Apple or Google identity token to the sign-in attempt that asked for it. The provider sees only the nonce's hash; the raw value stays in the app and travels only to Amba with that sign-in, so an identity token issued to another sign-in (a different app sharing your client id, or a different attempt) can't be exchanged without that attempt's raw nonce. Treat the sign-in request body like a password until it reaches Amba: the first request that carries the token and its raw nonce signs in, and every later one is refused.
-
@layers/amba-expodoes it for you.Amba.signInWithApple()andAmba.signInWithGoogle({...})draw a fresh random nonce for every attempt, give the provider its SHA-256 hash, and send the raw value to Amba with the token. They need a secure random source:react-native-get-random-values(imported first) orexpo-crypto. -
Running the provider flow yourself (React Native): for every attempt, draw a fresh random value from a secure source (32 bytes as hex is plenty). The provider request carries its SHA-256 hex digest as the nonce, and Amba gets the raw value:
signInWithGoogle(idToken, { nonce })andlinkAccount(provider, token, { nonce })take the same option. Some sign-in libraries hash the nonce for you; pass them the raw value in that case.
The server checks the token's nonce claim whenever the request carries a nonce, and requires the nonce for every token whose claim was set by an Amba SDK. A mismatch returns 401 INVALID_TOKEN. Each nonce is accepted once: after a sign-in or link that carried the raw nonce, the server keeps the digest of the token's nonce claim until the identity token expires, and a later /social or /link request with that token returns 401 NONCE_REUSED, whether or not it sends the raw nonce. A captured request body therefore cannot be sent again. A request that ends without signing in before it changes the account (the 503 retry-after answer, the MAU cap) gives the nonce back, so the app can resend it. Tokens requested without a nonce are accepted as before.
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. - The SDK keeps the user signed in. No auth-state-change fires. When a link adds an
identity, every other session of that user is signed out and the SDK stores the session
pair the link returns: always for Apple / Google, and for phone, email OTP, and email +
password on
@layers/amba-react-native4.0.14+ and@layers/amba-expo4.0.13+. On the other SDKs those three links keep the existing session token valid. - A new anonymous id. A link that adds an identity gives the user a new
anonymous_id. From then on the user signs in with the linked identity, andPOST /client/auth/anonymouswith the old anonymous id answers401 IDENTITY_SIGN_IN_REQUIRED. - 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. The error names no account. The SDK consumer decides whether to switch (call the matchingverify*instead, which signs in to the account that holds the identifier) 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':
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' (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
On React Native and Expo, signOut() also gives the device a new anonymous id whenever
the session it ends was not purely anonymous (any sign-in other than
signInAnonymously(), or an anonymous session that linked an identity), so the next
person on the device starts as a new anonymous user. signOut(true) gives it a new
anonymous id after an anonymous session too.
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. |
SESSION_REVOKED | The session was signed out: the account linked an identity on another sign-in, or its email was verified from another sign-in (it held an unverified password). Sign in again; the React Native and Expo SDKs end the session and tell onAuthStateChange subscribers. |
SIGN_IN_TEMPORARILY_UNAVAILABLE | 503: an email or Apple / Google sign-in that would take over an account with an unverified password waits a moment (a security update still rolling out, or a password linked to that account seconds earlier). Retry after Retry-After (at most 60s); the code or link stays valid. |
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, { nonce }) / Amba.auth.signInWithGoogle(idToken, { nonce }) under the hood once the native prompt returns, with a fresh sign-in nonce for every attempt:
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.