Two identifiers — don't confuse them
The SDK exposes two different ids. Picking the wrong one is the single most common integration mistake.
- Analytics anonymous id —
getAnonymousId(). A local UUID stored on-device (AsyncStorage / localStorage). It is for attribution and rotates when an anonymous user converts to a real account. Never key server data on it. - Auth user id (
sub) — the subject claim inside the access-token JWT. This is the durable identity you verify and link to. Stable across re-sign-in and across providers (email/Apple/Google), with one documented exception:merge(below).
Your backend should only ever trust the sub from a verified token — never a client-supplied id.
1. Verify the token against JWKS (never pin a key)
Point your verifier at the JWKS URL and let it discover keys — never paste a static public key, or a signing-key rotation will break every request. This is the standard OIDC pattern (Auth0, Clerk, Firebase, Supabase, Cognito, Okta, WorkOS all use it).
Your org's endpoints (ORG_ID is in Dashboard → Settings → General):
OIDC discovery: https://api.sendoracloud.com/api/v1/auth-service/<ORG_ID>/.well-known/openid-configuration
JWKS: https://api.sendoracloud.com/api/v1/auth-service/<ORG_ID>/.well-known/jwks.jsonNode / Fastify with jose:
import { createRemoteJWKSet, jwtVerify } from 'jose'
const ORG_ID = process.env.SENDORA_ORG_ID
const ISS = `https://api.sendoracloud.com/api/v1/auth-service/${ORG_ID}`
const JWKS = createRemoteJWKSet(new URL(`${ISS}/.well-known/jwks.json`))
export async function verifySendoraToken(accessToken) {
const { payload } = await jwtVerify(accessToken, JWKS, {
issuer: ISS, // PIN this — else any org's token would validate
algorithms: ['RS256'], // pin the algorithm
clockTolerance: 30, // seconds
// NO audience — Sendora tokens carry no `aud` claim
})
return payload // { sub, org, email, email_verified, is_anonymous, iat, exp, jti, ... }
}Token facts to encode in any verifier:
- Algorithm: RS256 only (no ES256).
- Issuer:
https://api.sendoracloud.com/api/v1/auth-service/<ORG_ID>— pin it. - Audience: there is no
audclaim; do not enforce one. Tenanting is viaiss+ theorgclaim. - Which token: verify the access token (from
auth.getAccessToken()). The refresh token is opaque — not a JWT. - Rotation: keys rotate with a
kidand a current+previous overlap window — keep using the JWKS, never cache a single key.
2. Key your data off the user — link, don't adopt
If you already mint your own local/offline id (common for offline-first and games), keep it as your primary key and treat sendora:<sub> as a linked attribute. That preserves offline-first boot and insulates you from the sub rotating on merge (next section). If you have no local id, you can use sub directly as the key — just handle merge.
create table identity_link (
local_id text primary key, -- your id (or the sub itself)
sendora_sub text unique, -- current verified sub
is_anonymous boolean not null default true,
linked_at timestamptz
);3. Anonymous → account: which conversions change sub
An anonymous user becomes a real account three ways, and only one keeps the same sub:
- Email + password upgrade (
signUpwhile anonymous → the/auth-service/upgradeendpoint): thesubis preserved — an in-place promotion of the same row. Nothing to remap; firesauth.user_upgraded. This is the only same-id conversion. - Social link-in-place while anonymous (
signInWithApple/signInWithGoogle/loginSocialwithlink: true— SDK ≥ RN 1.20.0 / Web 3.7.0 / iOS 4.7.0 / Android 4.6.0): for a brand-new Apple/Google identity thesubis preserved — the anonymous row is promoted in place (like FirebaselinkWithCredential/ SupabaselinkIdentity). Nothing to remap; firesauth.user_upgradedand returnsupgradedInPlace: true. Withoutlink(or on an older SDK) social sign-in is a device-takeover instead — the anon row is deleted, a newsubis minted, andauth.device_takeoverfires. - Merge into an existing account (a plain
signInto an account that already exists, or an explicitauth.merge()): the anonymoussubis retired and the canonical identity becomes the target account'ssub, so thesubchanges. Firesauth.user_merged; you must remap.
Bottom line: a brand-new identity keeps the id; adopting an existing one changes it. Email+password upgrade and social link: true (for a new Apple/Google identity) both PRESERVE the sub and fire auth.user_upgraded — no remap. Only a collision — the social identity already belongs to another account, or the email is taken — is unavoidably an id change (every provider fails the in-place link there): it falls back to a takeover / merge and you remap via the webhook below. So if you key game/economy data on an anonymous sub, pass link: true on conversion and consume the webhook only for the collision case. (An automatic device-takeover differs from an explicit auth.merge() only in that merge() also asks Sendora to reassign the anonymous user's events and profiles server-side. Both emit a webhook carrying old + new ids — key your remap off the webhook, not off which call the app made.)
Because you always re-verify the token and read the current sub, calling your link endpoint again after any conversion (with a freshly-fetched access token) handles both — the upsert updates sendora_sub on the same local_id, so your economy/game data never moves.
4. React to conversions server-side — webhooks
Instead of re-linking on every launch, subscribe a webhook endpoint (Dashboard → Webhooks) to these events and remap when they fire:
auth.user_merged— payload{ mergedUserId, deletedAnonymousId, eventsReassigned, profilesReassigned }. RemapdeletedAnonymousId→mergedUserIdin your store.auth.user_upgraded— payload{ userId, email, isAnonymous: false }. Thesub(userId) is unchanged; flip the identity to non-anonymous.auth.device_takeover— payload{ anonUserId, identifiedUserId, projectId }. Fires automatically when an anonymous user signs in to an existing account — the common conversion path, with noauth.merge()call. RemapanonUserId→identifiedUserId, exactly likeuser_merged. Subscribe to this too — otherwise you miss every sign-in-based conversion.auth.user_deleted— payload{ userId, email, projectId }. Fires when an account is permanently purged (self-service deletion after any grace period, admin deletion, SCIM deprovision, or Apple-ID account deletion). Consume it to erase or anonymize your own copy of the user's data — otherwise a user who deletes at Sendora leaves data stranded with you.
Webhook subscriptions are exact-match on the event name (or * for all). Payloads are delivered with the standard X-Sendora-Event header and the dispatcher retries on transient failures.
Guardrails
- Read the principal synchronously at cold start with
getUserSync()(React Native 1.19.0+) →{ id, isAnonymous }, zero network.idis thesub; key your data on it, not ongetAnonymousIdSync()(a rotating analytics id). Gate onisReady()so anullduring session restore isn't mistaken for signed-out. It's an unverified client read for routing/UI — your backend still re-verifies the token (above). - Call
auth.getAccessToken()(which refreshes a past-expiry token) before sending the Bearer to your backend — the SDK never returns an expired token, so your link/verify call won't 401 on a cold launch. - Gate cross-device sync on
is_anonymous === false. An anonymoussubis per-install and does not survive an uninstall/reinstall — don't treat it as a durable cross-device key until the user has a real account. - Your link endpoint should be idempotent (safe to call on every launch after auth).
Client-side: encrypt the SDK's tokens on device (React Native)
By default the React Native SDK persists tokens in AsyncStorage — OS-sandboxed but unencrypted (OWASP MASVS-L1, the same default as Firebase Auth and Supabase on RN). Since 1.18.0 you can opt into encryption-at-rest (OWASP L2) by passing a Keychain/Keystore-backed adapter to Sendora.init({ secureStorage }). Only the sensitive keys (refresh token, access token, cached user) route through it; existing tokens migrate transparently on first launch, so no one is signed out. The SDK does not depend on expo-secure-store — you supply the adapter (the same model as Supabase's auth.storage), which keeps bare-RN builds Metro-safe.
Ready-made adapter using expo-secure-store + expo-crypto (the "LargeSecureStore" hybrid — an AES key in the Keychain/Keystore encrypts the value kept in AsyncStorage, sidestepping SecureStore's 2 KB limit):
import * as SecureStore from 'expo-secure-store'
import * as Crypto from 'expo-crypto'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { QuickCrypto } from 'react-native-quick-crypto' // AES-256-GCM in RN
// Encrypts each value with a per-key AES-256 key stored in the Keychain/Keystore,
// keeping the ciphertext in AsyncStorage. Implements Sendora's SecureStorageAdapter.
const secureStorage = {
async getItem(key: string) {
const keyB64 = await SecureStore.getItemAsync('sk_' + key)
const blob = await AsyncStorage.getItem('enc_' + key)
if (!keyB64 || !blob) return null
return decryptAesGcm(blob, keyB64) // your AES-GCM decrypt
},
async setItem(key: string, value: string) {
let keyB64 = await SecureStore.getItemAsync('sk_' + key)
if (!keyB64) {
keyB64 = Buffer.from(Crypto.getRandomBytes(32)).toString('base64')
await SecureStore.setItemAsync('sk_' + key, keyB64)
}
await AsyncStorage.setItem('enc_' + key, encryptAesGcm(value, keyB64))
},
async removeItem(key: string) {
await SecureStore.deleteItemAsync('sk_' + key)
await AsyncStorage.removeItem('enc_' + key)
},
}
Sendora.init({ apiKey: 'pk_live_...', secureStorage })- iOS Keychain survives app reinstall; Android Keystore/EncryptedSharedPreferences does not — so encryption is about a rooted/jailbroken or forensically-imaged device, not reinstall continuity.
- This is defense-in-depth. The bigger win is that a stolen device image can't read a long-lived refresh token — short-lived access tokens are lower risk.
- Omit
secureStorageto keep the default AsyncStorage behaviour. Adding it never changes an app that doesn't pass it.