Amba

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

  1. On first launch, init() generates a local anonymous_id and 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.
  2. 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.
  3. The SDK attaches Authorization: Bearer <session_token> to every user-scoped request.
  4. 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).
  5. logout() revokes the refresh token server-side and clears local state.

JWT claims

Session tokens carry:

ClaimMeaning
subthe user's id
pidprojectId — defense in depth: the API rejects a token that doesn't match the API key's project
anonthe 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:

PathBehaviour
POST /client/auth/anonymousAlways generates — there's no name to take from the caller.
POST /client/auth/socialUses the provider's name claim if present (Google), generates if absent (Apple withholds name on subsequent sign-ins).
POST /client/auth/email/signupUses body.display_name if supplied, generates otherwise.
POST /client/auth/magic-link/verifyAlways 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:

curl -X PATCH '${BASE_URL}/client/users/me' \
  -H 'X-Api-Key: ${CLIENT_API_KEY}' \
  -H 'Authorization: Bearer ${SESSION_TOKEN}' \
  -H 'Content-Type: application/json' \
  -d '{ "display_name": "Alice" }'

For email signup, you can supply display_name directly in the request body so the user is never briefly labelled OakHiker in the UI:

curl -X POST '${BASE_URL}/client/auth/email/signup' \
  -H 'X-Api-Key: ${CLIENT_API_KEY}' \
  -H 'Content-Type: application/json' \
  -d '{ "email": "alice@example.com", "password": "…", "display_name": "Alice" }'

SDK usage

The Amba auth module signatures (same shape in @layers/amba-web, @layers/amba-node, @layers/amba-expo, and @layers/amba-react-native):

Amba.auth.signInAnonymously(): Promise<AuthResult>
Amba.auth.signInWithEmail(email: string, password: string): Promise<AuthResult>
Amba.auth.signUpWithEmail(email: string, password: string): Promise<AuthResult>
Amba.auth.signInWithApple(identityToken: string): Promise<AuthResult>
Amba.auth.signInWithGoogle(idToken: string): Promise<AuthResult>
Amba.auth.linkAccount(provider: 'apple' | 'google', idToken: string): Promise<AuthResult>
Amba.auth.linkEmailPassword(email: string, password: string): Promise<User>
Amba.auth.linkSmsOtp(phone: string, code: string): Promise<User>
Amba.auth.linkEmailOtp(email: string, code: string): Promise<User>
Amba.auth.unlinkSmsOtp(): Promise<User>
Amba.auth.unlinkEmailOtp(): Promise<User>
Amba.auth.unlinkApple(): Promise<User>
Amba.auth.unlinkGoogle(): Promise<User>
Amba.auth.getCurrentIdentifiers(): Promise<LinkedIdentifier[]>
Amba.auth.getSession(): Promise<Session | null>
Amba.auth.refresh(): Promise<Session>
Amba.auth.me(): Promise<AppUser>
Amba.auth.signOut(rotateAnonymousId?: boolean): Promise<void>
Amba.auth.onAuthStateChange(cb: (s: Session | null) => void): Unsubscribe

Anonymous identity (automatic)

import { Amba } from '@layers/amba-web';
 
await Amba.configure({
  projectId: 'proj_xxx',
  clientKey: 'amba_ck_xxx',
});
 
// Anonymous id is persisted automatically after configure().
const anon = Amba.anonymousId;
 
// You usually don't need to sign in explicitly: the first authenticated call
// (e.g. reading XP or a collection) lazily establishes an anonymous session.
// Call signInAnonymously() only when you want to control exactly when the
// server-side anonymous user is created (it counts as a monthly active user).
await Amba.auth.signInAnonymously();

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

// Sign up — creates a new user and persists a session.
const signup = await Amba.auth.signUpWithEmail('alice@example.com', 'correct horse battery staple');
console.log(signup.user.id, signup.session_token, signup.refresh_token);
 
// Sign in — verifies the password and persists a session.
const login = await Amba.auth.signInWithEmail('alice@example.com', 'correct horse battery staple');

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 with Amba.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 with linkEmailPassword. 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.

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:

// 1. From the login screen — your app posts an email and we send the link.
//    Always returns 200 — we don't surface "this email isn't registered" to
//    avoid leaking which addresses are users.
await fetch(`${BASE_URL}/client/auth/magic-link/request`, {
  method: 'POST',
  headers: { 'X-Api-Key': API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'alice@example.com' }),
});
 
// 2. The user clicks the link `https://<your-origin>/auth/verify?token=<raw>`.
//    Your verify-page extracts the token from the URL and POSTs it back. The
//    response shape matches /email/login: session_token, refresh_token, user.
const res = await fetch(`${BASE_URL}/client/auth/magic-link/verify`, {
  method: 'POST',
  headers: { 'X-Api-Key': API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ token }),
});
const { data } = await res.json();
// data.session_token, data.refresh_token, data.user

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:

{
  "magic_link_redirect_url": "https://app.example.com/auth/callback",
  "auth_email_brand_name": "Example App"
}

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

// 1. From the login screen — your app posts an email and we send the code.
//    Always returns 200 — we don't surface "this email isn't registered"
//    to avoid leaking which addresses are users.
await Amba.auth.requestEmailOtp('alice@example.com');
 
// 2. The user reads the code from their email and types it into your UI.
//    The verify response shape matches /email/login: session_token, refresh_token, user.
const result = await Amba.auth.verifyEmailOtp('alice@example.com', '123456');
console.log(result.session_token, result.refresh_token, result.user);

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.

# Raw HTTP (no SDK):
curl -X POST https://api.amba.dev/v1/client/auth/email-otp/request \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com"}'
 
curl -X POST https://api.amba.dev/v1/client/auth/email-otp/verify \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","code":"123456"}'

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.

// 1. Request a code. Phone must be E.164.
await Amba.auth.requestSmsOtp('+15551234567');
 
// 2. The user reads the code from their SMS inbox and types it in.
const result = await Amba.auth.verifySmsOtp('+15551234567', '123456');
console.log(result.session_token, result.user);

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:

Tierper phone per hourper phone per day
Free310
Pro1050
Scale50500
Enterprise— (no cap)—

Per-IP caps stay constant across tiers (10/min, 200/day) as abuse defense.

curl -X POST https://api.amba.dev/v1/client/auth/sms-otp/request \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"phone":"+15551234567"}'
 
curl -X POST https://api.amba.dev/v1/client/auth/sms-otp/verify \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"phone":"+15551234567","code":"123456"}'

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:

# Via the CLI / MCP
amba projects update --bundle-id com.example.myapp
 
# Or PATCH directly
curl -X PATCH https://api.amba.dev/v1/admin/projects/$PROJECT_ID \
  -H "Authorization: Bearer $DEV_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"bundle_id":"com.example.myapp"}'

Without this, signInWithApple returns 400 AUDIENCE_NOT_CONFIGURED.

Runtime call:

// 1. Get an Apple identity token from the native flow.
//    In Expo, use the @layers/amba-expo helper below.
const identityToken = await appleAuth.signIn({ ... }).then(r => r.identityToken);
 
// 2. Exchange it for an Amba session.
const result = await Amba.auth.signInWithApple(identityToken);

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:

amba projects update --google-oauth-client-id 123456.apps.googleusercontent.com
 
# Or PATCH directly
curl -X PATCH https://api.amba.dev/v1/admin/projects/$PROJECT_ID \
  -H "Authorization: Bearer $DEV_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"google_oauth_client_id":"123456.apps.googleusercontent.com"}'

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:

const idToken = await googleAuth.signIn().then((r) => r.idToken);
const result = await Amba.auth.signInWithGoogle(idToken);

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-expo does it for you. Amba.signInWithApple() and Amba.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) or expo-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:

    // identityToken came from a provider request whose nonce was SHA-256(rawNonce) in hex.
    await Amba.auth.signInWithApple(identityToken, { nonce: rawNonce });

    signInWithGoogle(idToken, { nonce }) and linkAccount(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_id before 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-native 4.0.14+ and @layers/amba-expo 4.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, and POST /client/auth/anonymous with the old anonymous id answers 401 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_LINKED error. The error names no account. The SDK consumer decides whether to switch (call the matching verify* instead, which signs in to the account that holds the identifier) or abort.

Social (Apple, Google)

// User is anonymous right now; link their Apple identity without creating
// a new user row.
await Amba.auth.linkAccount('apple', identityToken);
// or
await Amba.auth.linkAccount('google', idToken);

Phone via SMS OTP

// Reuses the same /sms-otp/request you'd use for a fresh sign-in.
await Amba.auth.requestSmsOtp('+15551234567');
 
// On code receipt, call linkSmsOtp INSTEAD of verifySmsOtp.
// → same user_id; user.phone set, user.phone_verified = true. The user stays signed in.
const user = await Amba.auth.linkSmsOtp('+15551234567', '123456');

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':

try {
  await Amba.auth.linkSmsOtp('+15551234567', '123456');
} catch (err) {
  if (err instanceof AmbaApiError && err.code === 'PHONE_ALREADY_LINKED') {
    // Decide: switch to the account that holds the phone via
    // requestSmsOtp + verifySmsOtp, or abort.
  }
}

Email via OTP

Symmetric to SMS:

await Amba.auth.requestEmailOtp('alice@example.com');
const user = await Amba.auth.linkEmailOtp('alice@example.com', '123456');
// Collision code: EMAIL_ALREADY_LINKED.

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.

// User is anonymous; attach email + password. Same user_id, and the user
// stays signed in. user.email is set; email_verified stays false until
// the inbox is proven (linkEmailOtp with the same address keeps the password).
// An address this user already verified stays verified.
const user = await Amba.auth.linkEmailPassword('alice@example.com', 'correct horse battery staple');
 
// After linking, the user can sign in on a new device with the same credentials:
// await Amba.auth.signInWithEmail('alice@example.com', 'correct horse battery staple');

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

// What's already attached? Identifier values come back MASKED — raw
// values stay server-side.
const ids = await Amba.auth.getCurrentIdentifiers();
// [
//   { provider: 'sms_otp',   verified: true, identifier_masked: '+1***4567' },
//   { provider: 'email_otp', verified: true, identifier_masked: 'a***@example.com' },
//   { provider: 'apple',     verified: true, identifier_masked: null },
// ]
 
// Remove one. Refused with LAST_IDENTIFIER if it would orphan the
// account — link a replacement first, then unlink.
await Amba.auth.unlinkSmsOtp();
await Amba.auth.unlinkEmailOtp();
await Amba.auth.unlinkApple();
await Amba.auth.unlinkGoogle();

Rate-limit headers

Every /sms-otp/* and /email-otp/* response carries:

HeaderMeaning
X-RateLimit-LimitMax requests in the most restrictive window
X-RateLimit-RemainingRequests left in that window
X-RateLimit-ResetSeconds 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

// Get current session (null if not signed in)
const session = await Amba.auth.getSession();
if (session) console.log('Signed in as', session.user.email);
 
// Listen for auth state changes
const unsubscribe = Amba.auth.onAuthStateChange((session) => {
  if (session) {
    console.log('Signed in:', session.user.id);
  } else {
    console.log('Signed out');
  }
});
// ...later
unsubscribe();
 
// Sign out — best-effort server revoke; always clears local state.
await Amba.auth.signOut();

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:

interface Session {
  sessionToken: string;
  refreshToken: string;
  user: AppUser;
  expiresAt: string; // ISO-8601
}

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():

const newSession = await Amba.auth.refresh();

Current user

Amba.auth.me() fetches /client/users/me using the current session token and returns the AppUser record:

const user = await Amba.auth.me();
console.log(user.email, user.display_name, user.properties);

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:

import { Amba, AmbaApiError } from '@layers/amba-web';
 
try {
  await Amba.auth.signInWithEmail('alice@example.com', 'hunter2');
} catch (err) {
  if (err instanceof AmbaApiError) {
    if (err.code === 'INVALID_CREDENTIALS') {
      // Show "wrong email or password" copy.
    } else if (err.code === 'RATE_LIMITED') {
      // Back off and prompt the user to try again in a minute.
    } else {
      // Fall through to a generic error toast.
    }
  } else {
    // Non-Amba throw (network teardown, mid-flight JS error). Rare.
  }
}

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

CodeWhen
INVALID_CREDENTIALSWrong email/password on signInWithEmail.
USER_EXISTSEmail already registered on signUpWithEmail.
INVALID_EMAILMalformed email.
WEAK_PASSWORDPassword doesn't meet policy.
RATE_LIMITEDToo many attempts from this IP / user.
INVALID_TOKENApple/Google token failed signature verification.
SESSION_REVOKEDThe 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_UNAVAILABLE503: 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:

import { Amba } from '@layers/amba-expo';
 
function SignInScreen() {
  const handleApple = async () => {
    try {
      await Amba.signInWithApple();
    } catch (err) {
      // User cancelled, or Apple Sign In not available on this device.
    }
  };
 
  const handleGoogle = async () => {
    // Requires `googleIosClientId: '...'` passed to the Expo config plugin
    try {
      await Amba.signInWithGoogle();
    } catch (err) {
      /* ... */
    }
  };
 
  return (
    <>
      <Button title="Continue with Apple" onPress={handleApple} />
      <Button title="Continue with Google" onPress={handleGoogle} />
    </>
  );
}

For email auth, call Amba.auth.signUpWithEmail(...) and Amba.auth.signInWithEmail(...) directly — they're available on every Amba SDK.

Routes reference

MethodPathDescription
POST/client/auth/anonymousCreate a new anonymous user + session
POST/client/auth/socialExchange an Apple or Google identity token for an Amba session
POST/client/auth/email/signupEmail signup (bcrypt-hashed password)
POST/client/auth/email/loginEmail sign-in (verifies bcrypt hash)
POST/client/auth/magic-link/requestSend a one-tap sign-in link to the given email
POST/client/auth/magic-link/verifyExchange a magic-link token for a session
POST/client/auth/email-otp/requestSend a 6-digit one-time passcode to the given email
POST/client/auth/email-otp/verifyExchange (email, code) for a session
POST/client/auth/sms-otp/requestSend a 6-digit one-time passcode by SMS to the given E.164 phone
POST/client/auth/sms-otp/verifyExchange (phone, code) for a session
POST/client/auth/linkLink an identifier (apple, google, sms_otp, email_otp) to current session
POST/client/auth/unlinkDetach an identifier from the current session's user
GET/client/auth/identifiersList identifier providers attached to current user (masked)
POST/client/auth/refreshRotate session + refresh tokens
POST/client/auth/logoutSign 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.