Skip to content
Sendora Cloud
Create account
Reference

Using Sendora identity in your own backend

How to consume Sendora-issued end-user identity server-side — verify the JWT against JWKS, key your own data off the user, and stay correct across anonymous → account conversion. For teams running their own backend (PostgREST, nginx auth_request, Lambda authorizers, Express/Hono/Fastify/Spring/Rails) alongside a Sendora SDK.

Already hit one of these? The answer probably already ships.
  • One app, two projects (dev/prod, staging/live) and the sessions collide → SendoraCloud.init({ storagePrefix }) gives each project its own storage namespace so they can't adopt each other's session. ⚠ Set it before launch — it renames every storage key, so turning it on in a live app signs that install out. Confirm which build carries which key with GET /api/v1/me.
  • An offline first launch (a game, an offline-first app) — two ids, two jobs, and they must not be merged:
    • Correlation & analytics → getAnonymousIdSync(): a client-minted id available offline at first launch, now stable across identify/upgrade. Pair it with getUserSync() + isReady() to tell “still restoring” apart from “signed out.” ⚠ It rotates at sign-out and account deletion by design (so two people on a shared device don't merge), so it is not a durable key.
    • Durable local state that outlives a sign-out (a balance, entitlements, purchase history) → own a separate key yourself in secure storage, and reconcile it to the server subject once, on first connectivity. The SDK will not do this for you: the subject is server-minted, so an offline first launch has no subject until it connects. ⚠⚠ Skip it and the failure is silent — writes and rewards land on a subject the server can't resolve, with no error and no 5xx, surfacing later as “my coins are gone,” not as anything traceable to the SDK.
  • Verifying which build shipped which key → GET /api/v1/me resolves a publishable key to { projectId, environment, orgId }. It's a stateless x-api-key GET (no device, no session), so it drops straight into a CI build gate — extract the baked key and assert it resolves to the project you intended before you upload. An unrecognised key returns a clean 401.

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

A 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 aud claim; do not enforce one.
  • email_verified is 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 with false (Entra's mail can 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 by sub (with project), never by email. Treat email_verified as 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 + the org claim identify the workspace, and the project claim 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, compare payload.project to 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 absentproject key 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.
  • project may be null for accounts in the org-wide pool (SSO/SCIM provisioning). null means “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 kid and 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 (signUp while anonymous → the /auth-service/upgrade endpoint): the sub is preserved — an in-place promotion of the same row. Nothing to remap; fires auth.user_upgraded. This is the only same-id conversion.
  • Social link-in-place while anonymous (signInWithApple / signInWithGoogle / loginSocial with link: 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 the sub is preserved — the anonymous row is promoted in place (like Firebase linkWithCredential / Supabase linkIdentity). Nothing to remap; fires auth.user_upgraded and returns upgradedInPlace: true. Without link (or on an older SDK) social sign-in is a device-takeover instead — the anon row is deleted, a new sub is minted, and auth.device_takeover fires.
  • Merge into an existing account (a plain signIn to an account that already exists, or an explicit auth.merge()): the anonymous sub is retired and the canonical identity becomes the target account's sub, so the sub changes. Fires auth.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 }. Remap deletedAnonymousId → mergedUserId in your store.
  • auth.user_upgraded — payload { userId, email, isAnonymous: false }. The sub (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 no auth.merge() call. Remap anonUserId → identifiedUserId, exactly like user_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). reason is optional: present only as guest_discarded or workspace_deleted, absent on every other purge — branch on its presence, never expect it. A guest_discarded purge is immediate (no grace) and its email is null. 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 its sub — events, including guest events merged onto the account by auth.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 an anonymous_id and 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 through POST /auth-service/guest/held with the guest's own refresh token, after the hold committed. Nothing is deleted and nothing on the guest changes: guestUserId is a live account the player can still restore or claim, and Sendora runs no timer on it. There is deliberately no identifiedUserId — 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 second guest_held for a guest you already track is a no-op. Resolution is the app's: claimHeldGuest() folds the guest into the current account (you then receive auth.user_merged with deletedAnonymousId = this guestUserId), discardHeldGuest() purges it (auth.user_deleted with reason: "guest_discarded"), or your own admin delete — see "Sweeping orphaned guests" below. Ordering: auth.guest_held and the auth.user_merged / auth.user_deleted that 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 late guest_held for 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. id is the sub; key your data on it, not on getAnonymousIdSync() (a rotating analytics id). The user object every auth response returns also carries project — the account's project pool, an explicit null for the org-wide SSO/SCIM pool, the same value as the JWT project claim — so a client can pin tenancy from the stored user record when no access token is decodable (typed on the SDK AuthUser from the wave-1 SDKs; absent only from a backend older than ADR-033 wave 0). Gate on isReady() so a null during 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 anonymous sub is 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 secureStorage to keep the default AsyncStorage behaviour. Adding it never changes an app that doesn't pass it.