Skip to content
Sendora Cloud
Create account
Identity

Auth failures & offline behaviour

Sign-in fails for ordinary reasons all the time — a subway tunnel, a mistyped code, a tapped link that already expired. This page covers what your app sees when that happens, and the one guarantee everything else is built on.

The guarantee

A failed auth attempt leaves the user exactly as it found them. If a sign-in rejects, the session that was there before is still there: getUserSync() returns the same user and the stored refresh token is untouched. Nothing is signed out, nothing is orphaned, and the user can simply try again.

This matters most for anonymous sessions. A player who has never signed in is held together entirely by the refresh token on the device — lose it and the account behind it becomes unreachable, with its purchases and progress stranded server-side. So the SDK never clears a session speculatively: it performs the call, validates the response, and only then swaps identities.

Minimum versions. React Native 1.29.0 · Web 3.13.0 · iOS 4.14.0 · Android 4.14.0 — and 1.30.0 / 3.14.0 / 4.15.0 / 4.15.0 for the collision policy below. Earlier releases cleared local identity before the network call on several sign-in paths, so a failure could strand an anonymous account. On Web, iOS and Android, earlier releases also never completed a token refresh — the session ended at access-token expiry and the app minted a fresh anonymous user in its place. Upgrade if your app signs users in on launch, and do not stop short of these versions.

Offline

Offline is not a special case — it is just the failure above, arriving with certainty instead of occasionally. Concretely:

  • init() reads from disk only. It resolves offline, and the session saved on the device is restored.
  • getUserSync() and isReady() work offline and never touch the network. This is how a game boots straight into play with no connectivity.
  • getAccessToken() returns a valid cached token, or null when it cannot mint a fresh one. It never returns an expired token, and it never signs the user out because the network was unavailable.
  • Any sign-in attempted offline rejects with kind: "network" and changes nothing.
  • signOut() works offline. The local session clears immediately; the token revocation is fire-and-forget.

A transient rate limit is treated the same way. A 429 on the background token refresh means "wait", not "this session is dead" — the SDK backs off and keeps the session.

Reading the error

Every rejection is an AuthError carrying a kind — a closed set you can switch on — plus retryable, and retryAfterSeconds when the server supplied one. The raw code and message are still there for logging.

kindWhat happenedRetry?
networkThe device is offline or the request timed out.Yes — same input, once connectivity returns.
serverA 5xx, or a response we could not parse.Yes — same input, later.
rate_limitedToo many attempts. Read retryAfterSeconds.Yes — after the stated wait.
invalid_credentialWrong password or code, expired magic link, stale OAuth code. Also the deliberate response for a disabled account.No — collect new input.
account_lockedToo many failed sign-ins locked the account. retryAfterSeconds present = unlocks on its own; absent = needs support.Yes — after the cool-off, if one is given.
credential_in_useThat credential already belongs to a different account.No — sign in to that account instead.
already_identifiedThe session is already signed in. Use a link method to add a credential.No.
cancelledThe user dismissed a passkey, Game Center, or identity-provider sheet.No — not an error condition.
configThe method is disabled or plan-gated for this project.No — fix it in the dashboard.
unknownUnmapped. Treat as non-retryable and surface the message.No.
try {
  await sendora.auth.signInWithGameCenter({ link: true });
} catch (err) {
  switch (err.kind) {
    case "network":
      // Keep playing. The anonymous session is still live and intact.
      showToast("You're offline — we'll link your account next time.");
      break;
    case "rate_limited":
    case "account_locked":
      showToast(`Try again in ${err.retryAfterSeconds ?? 60}s.`);
      break;
    case "cancelled":
      break; // The user backed out. Nothing to report.
    case "invalid_credential":
      showToast("That didn't work — check the code and try again.");
      break;
    default:
      showToast(err.message);
  }
}

A locked account is worth calling out: it returns kind: "account_locked" with a retryAfterSeconds countdown for the automatic cool-off after repeated wrong passwords. A lock that needs support arrives with no retryAfterSeconds at all — the absence is the signal. An account an operator has disabled deliberately reports as invalid_credential, indistinguishable from a wrong password, so the API cannot be used to discover which addresses have accounts.

When the credential already belongs to someone

There is one outcome the guarantee above does not cover, because it is not a failure. If you sign in with a Game Center, Play Games or social identity that is already attached to an account, the default is to sign you into that account — and if this device held an anonymous session, that session is retired and its row deleted. It returns 200, so there is no error to catch.

That default is deliberate: a player identity survives an app reinstall server-side, so the collision is usually the same person's earlier account, and adopting it is how they get their progress back. But if the local anonymous session holds progress you are not willing to trade, say so:

try {
  await sendora.auth.signInWithGameCenter({
    link: true,
    onCredentialInUse: "reject",   // default is "adopt"
  });
} catch (err) {
  if (err.kind === "credential_in_use") {
    // Nothing changed. The anonymous session is still live and intact.
    const useOther = await askUser(err.collision === "email"
      ? "An account already exists for that email."
      : "That player account already exists.");
    if (useOther) {
      // Deliberately adopt it — this is what retires the local account.
      await sendora.auth.signInWithGameCenter({ link: true });
    }
  }
}
  • "reject" fails with CREDENTIAL_IN_USE and changes nothing at all — no takeover, no deletion.
  • err.collision is "identity" (that provider identity is linked elsewhere) or "email" (the provider's verified email belongs to another account).
  • Reject blocks the switch; it does not merge. If the player owns both accounts, rejecting strands them from the other side. To adopt and carry data across, re-call without the policy and reconcile from retiredAnonUserId (or the device-takeover webhook, which is authoritative and survives an app kill mid-flight).
  • Omitting onCredentialInUse is exactly the behaviour of every earlier release, so nothing you ship today changes.

Mirrors Firebase's split between signInWithCredential (adopts) and linkWithCredential (throws credential-already-in-use), and Supabase's linkIdentity. You can also get the reject semantics by calling linkGameCenter() / linkSocial() directly — from an anonymous session those now promote the account in place, keeping the same user id, and never adopt another account.

Watching the session

Failures you can catch are the easy half. The other half is a session that ends while nobody is looking — the refresh token was revoked from another device, or the account was deleted. Subscribe once and every transition arrives on the same stream:

const unsubscribe = sendora.auth.onAuthStateChanged((change) => {
  switch (change.event) {
    case "signed_in":
      startSession(change.user);
      break;
    case "signed_out":
      if (change.reason === "session_expired") promptSignInAgain();
      if (change.reason === "account_deleted") clearLocalData();
      break;
    case "device_takeover":
      migrateProgress(change.retiredAnonUserId, change.user.id);
      break;
    case "deletion_cancelled":
      restoreAccountUi();
      break;
  }
});
  • signed_out with reason session_expired is the one you cannot get any other way: the server rejected the stored refresh token. Network failures and rate limits never produce it.
  • Subscribing replays the current state, so a listener added after startup still learns that a session was restored from disk.
  • A failed sign-in emits nothing. No state changed, so there is no transition to report — the rejected promise is the whole story.

Designing for it

Because a failure costs nothing, the safe pattern is simply to let it fail. Do not pre-flight a connectivity check before calling a sign-in method, and do not re-mint a guest session after one fails — the previous session is still there, and minting a second one is what actually loses the first.

For a game or any offline-first app: boot from getUserSync(), attempt the upgrade or link opportunistically, and treat kind: "network" as "not yet" rather than an error worth surfacing. Retry on the next launch.

If your backend mirrors Sendora user ids, pair this with the device-takeover and identity webhook flows so a sub that changes on one device reconciles everywhere.