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

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

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 @layers/amba-client auth module signatures:

client.auth.getAnonymousId(): Promise<string>
client.auth.loginWithApple(identityToken: string): Promise<AuthResult>
client.auth.loginWithGoogle(idToken: string): Promise<AuthResult>
client.auth.signUpWithEmail(email: string, password: string): Promise<AuthResult>
client.auth.loginWithEmail(email: string, password: string): Promise<AuthResult>
client.auth.linkAccount(provider: 'apple' | 'google', token: string): Promise<AuthResult>
client.auth.getSession(): Promise<Session | null>
client.auth.refresh(): Promise<Session>
client.auth.me(): Promise<AppUser>
client.auth.logout(): Promise<void>
client.auth.onAuthStateChange(cb: (s: Session | null) => void): Unsubscribe

Anonymous identity (automatic)

import { Amba } from '@layers/amba-client';
 
const client = Amba.configure({
  projectId: 'proj_xxx',
  apiKey: 'amb_dev_ck_xxx',
});
 
await client.init();
// Anonymous id is now persisted in storage
const anon = await client.auth.getAnonymousId();

Email signup / login

// Sign up — creates a new app_users row + persists a session
const signup = await client.auth.signUpWithEmail(
  'alice@example.com',
  'correct horse battery staple',
);
console.log(signup.user.id, signup.session_token, signup.refresh_token);
 
// Log in — verifies bcrypt, persists a session
const login = await client.auth.loginWithEmail('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 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

// 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 client.auth.loginWithApple(identityToken);

The server verifies the token's signature + issuer against Apple's JWKS before trusting any claims.

Google Sign In

const idToken = await googleAuth.signIn().then((r) => r.idToken);
const result = await client.auth.loginWithGoogle(idToken);

Same JWKS verification on the server.

Account linking

Preserve a user's anonymous history when they later add a social login:

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

Email linking goes through the verified signUpWithEmail / loginWithEmail flow, not linkAccount.

Session management

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

Session shape:

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

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

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

Current user

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

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

Common AmbaApiError.code values on auth

CodeWhen
INVALID_CREDENTIALSWrong email/password on loginWithEmail.
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:

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

For email auth just use Amba.auth.signUpWithEmail(...) / Amba.auth.loginWithEmail(...) — the auth accessor is passed through to the underlying AmbaClient.

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 login (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/linkLink the current anonymous session to a social provider
POST/client/auth/refreshRotate session + refresh tokens
POST/client/auth/logoutRevoke 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

TablePurpose
app_usersCore user record: id, email, anonymous_id, display_name, auth_providers (JSONB), properties (JSONB), password_hash, timestamps
account_linksAudit log of anonymous → authenticated transitions
app_user_sessionsServer-side refresh-token records. Each carries a sha256 hash of the refresh token, expires_at, revoked_at, user_agent, and ip
magic_link_tokensPending magic-link sign-in tokens: email, token_hash (sha256), expires_at, used_at, optional app_user_id link