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.

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

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
  })
  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 aud claim; do not enforce one. Tenanting is via iss + the org claim.
  • 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 }. Remap deletedAnonymousIdmergedUserId 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 }. 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. id is the sub; key your data on it, not on getAnonymousIdSync() (a rotating analytics id). 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 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 secureStorage to keep the default AsyncStorage behaviour. Adding it never changes an app that doesn't pass it.