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.jsonA wrong ORG_ID answers 404 (cached for 5 minutes), not an empty key set — check the id before you look anywhere else. After a signing-key rotation the new key is served on the very next request; the displaced key stays in the set for the overlap window.
Node / 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
})
// A workspace can hold several projects, and they share one issuer and one
// signing key — so `iss` alone does NOT separate them. Pin the project too.
if (payload.project !== PROJECT_ID) throw new Error('wrong project')
return payload // { sub, org, project, 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. email_verifiedis not proof of ownership by default. It is true for a provider-verified sign-in, a magic link or email code, and a password sign-up that clicked the verification link — and also for every password sign-up in a project that does not require email verification (the default), whatever address was typed. A Microsoft account is created withfalse(Entra'smailcan be set to any address by any tenant admin; an account created before 2026-10-16 keeps the value it was created with), and so is an Apple Work & School address Apple did not verify. Identify a user bysub(withproject), never by email. Treatemail_verifiedas proof of the address only if your project requires email verification. A sign-in whose provider did not verify the address never links onto an existing account with that address.- Tenanting:
iss+ theorgclaim identify the workspace, and theprojectclaim identifies the project within it. Check both. A workspace can hold several projects, and they share one issuer and one signing key — so a token minted for a sibling project verifies against your issuer perfectly. If you serve one project, comparepayload.projectto your project id and reject anything else. - ⚠ Rolling out a strict project check: a session only gains the claim when it mints new tokens. A cold app launch reuses a still-valid cached access token and does not refresh, so a device can keep presenting a pre-claim token across several relaunches. Worst case is one full access-token lifetime — 60 minutes by default(
sessionTtlMinutes, per project, under Auth → Settings → Sign-in). Enforce the wrong-project rejection immediately (a sibling project’s token is never legitimate), but tolerate an absentprojectkey for one TTL before rejecting on it — otherwise every session still holding a pre-claim token 401s on everything at once, and from the app it is indistinguishable from your backend being down. Logging the claim at boot turns that failure from a mystery into one line. projectmay benullfor accounts in the org-wide pool (SSO/SCIM provisioning).nullmeans “genuinely not in a project”; the key being absent means the token predates this claim. Treat both as failing a project check unless you deliberately support org-wide users.- 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, pushTokensReassigned, projectId }. 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, reason? }. Fires when an account is permanently purged (self-service deletion after any grace period, admin deletion, SCIM deprovision, Apple-ID account deletion, an app's explicit held-guest discard —discardHeldGuest()→POST /auth-service/guest/discard— or the whole workspace being deleted).reasonis optional: present only asguest_discardedorworkspace_deleted, absent on every other purge — branch on its presence, never expect it. Aguest_discardedpurge is immediate (no grace) and itsemailisnull. 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. Sendora's own copy goes at the same moment: a purge erases the account row, its credentials (passkeys, MFA, sessions, social identities) and the data keyed to itssub— events, including guest events merged onto the account byauth.merge()or a device takeover, the profile, entities, push-token registrations, SMS sends, and the support tickets it filed while signed in (a ticket's replies are not yet erased with it — a fix is tracked). Events that only ever carried ananonymous_idand were never attributed to the account are not reachable from it and are not erased. Records matched only by the account's email address — email-only support tickets, a profile no account owns, and email sends (which carry no account id at all) — are erased only when the account had proven it owns that address (a magic-link or email-code sign-up; a Google, Apple, GitHub, LinkedIn, Facebook or Discord sign-up; a Google, GitHub, LinkedIn, Facebook or Discord linked identity carrying that address; or a password sign-up in a project that requires email verification), and only within the account's own project (an account created by magic link or email code belongs to the whole workspace, so its records go in every project). A ticket or profile that another account owns is never erased by address. Otherwise anyone could sign up with someone else's address and delete the account to erase that person's data. For a request about an address, use a GDPR erasure request.auth.guest_held— payload{ guestUserId, projectId, heldAt }. Fires when a device signs in to a different existing account and keeps its guest (anonymous) account on the device instead of retiring it — the SDK reports the hold throughPOST /auth-service/guest/heldwith the guest's own refresh token, after the hold committed. Nothing is deleted and nothing on the guest changes:guestUserIdis a live account the player can still restore or claim, and Sendora runs no timer on it. There is deliberately noidentifiedUserId— the account the device switched to is not in this payload. Delivery is at-least-once and the SDK may re-report the same hold, so a secondguest_heldfor a guest you already track is a no-op. Resolution is the app's:claimHeldGuest()folds the guest into the current account (you then receiveauth.user_mergedwithdeletedAnonymousId= thisguestUserId),discardHeldGuest()purges it (auth.user_deletedwithreason: "guest_discarded"), or your own admin delete — see "Sweeping orphaned guests" below. Ordering:auth.guest_heldand theauth.user_merged/auth.user_deletedthat resolves it can arrive back-to-back in either order, so treat a merge or delete for a guest you hold no timer for as terminal — not as a no-op — and never re-arm a timer from a lateguest_heldfor a guest you have already seen resolved.
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.
Sweeping orphaned guests from your receiver
Sendora never deletes an account on an assumption — a held guest that nobody claims, restores or discards stays until somebody asks. If your app wants a cutoff, that decision lives in your receiver, and two admin-API additions make it safe to act on: on auth.guest_held, start a timer keyed on guestUserId and remember heldAt; cancel it on auth.user_merged / auth.user_deleted for that id; when it fires, call DELETE /orgs/$ORG_ID/auth-service/users/$GUEST_ID?unlessActiveSince=<heldAt> with a secret key holding auth-service:write. The server locks the row and compares its lastLoginAt — which every silent token refresh bumps, not only sign-ins — against the instant you pass: 412 PRECONDITION_FAILED (with details.lastLoginAt) means the player came back since the hold and nothing was deleted, so do nothing; 200 means the guest was purged and auth.user_deleted follows. A key bound to one project can only reach that project's users (a sibling project's guest is a plain 404). For a periodic sweep rather than a timer, list the population first: GET /orgs/$ORG_ID/auth-service/users?isAnonymous=true&lastLoginBefore=<iso> returns guests idle since that instant; add includeNeverLoggedIn=true only if you also mean accounts that never authenticated at all — "idle since" and "never active" are different populations and the API will not merge them for you.
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). Theuserobject every auth response returns also carriesproject— the account's project pool, an explicitnullfor the org-wide SSO/SCIM pool, the same value as the JWTprojectclaim — so a client can pin tenancy from the stored user record when no access token is decodable (typed on the SDKAuthUserfrom the wave-1 SDKs; absent only from a backend older than ADR-033 wave 0). 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 SendoraCloud.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 SendoraCloud from '@sendoracloud/sdk-react-native'
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)
},
}
await SendoraCloud.init({ apiKey: 'pk_prod_...', 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.