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

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

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

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.
  • 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_LINKED error and the conflicting user_id in error.details.conflicting_user_id — the SDK consumer decides whether to switch (call the matching verify* instead) 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.
// → existing session_token stays valid; same user_id; user.phone set, user.phone_verified = true.
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' and details.conflicting_user_id:

try {
  await Amba.auth.linkSmsOtp('+15551234567', '123456');
} catch (err) {
  if (err instanceof AmbaApiError && err.code === 'PHONE_ALREADY_LINKED') {
    const conflictingUserId = err.details?.conflicting_user_id;
    // Decide: switch to that identity via 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, same session
// token (no rotation). user.email is set and email_verified = true — a
// password-protected link is the user's active declaration of ownership.
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' 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

// 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();

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.

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:

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.