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 React Native 1.32.0 · Web 3.16.0 · iOS 4.17.0 · Android 4.17.0 for the safe collision default below. Between 1.30.0 and 1.31.0 the policy existed but you had to opt into it; from 1.32.0 it is the default and you do not. 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.

The device clock

getAccessToken() checks the token against two independent deadlines, and serves it only if both agree. They exist because each covers the case the other cannot:

  • A deadline the SDK tracks locally, computed as now + expiresIn when the token is minted. This is immune to a clock that is permanently wrong — the error cancels, because the deadline is written in the same frame it is later read in. It cannot see a clock that moves.
  • The token's own exp, which is written in the server's frame. This survives a clock change, and is wrong by the full offset on a device whose clock is simply wrong.

The case that needs both is a clock that is corrected after a token was minted — a phone acquiring automatic time following a long power-off, a restore, or a manual set. The locally-tracked deadline was written in the old frame and still claims the token is alive, by exactly the size of the correction, and it survives relaunches because it is persisted.

A device whose clock is genuinely fast is handled without penalty. The first time the two deadlines disagree the SDK refreshes once; if the brand-new token also reads as expired, that is proof the clock is fast rather than the deadline stale, and the SDK stops second-guessing it for the rest of the process. The cost is one refresh, not one per call.

If your app has its own evidence that the cached token is wrong — you decode exp yourself, or you just took a 401 — ask for a fresh one explicitly:

const token = await sendora.auth.getAccessToken({ forceRefresh: true });

It skips the cache but not the single-flight or the backoff cooldown, so calling it on every 401 cannot turn a server outage into a hot loop. getAccessTokenSync() is the deliberate exception to all of this: it returns whatever is cached, expired or not, and applies no expiry guard at all. Prefer the async form unless you specifically want the raw cached value.

One codebase, two projects

Everything the SDK stores on the device is namespaced under a single prefix. If you point one codebase at more than one Sendora project — a staging build beside a release build — both share that namespace, and whichever ran last owns the session. Give each environment its own:

Sendora.init({ apiKey, storagePrefix: "sendora_staging_" });

Omit it and nothing changes; the default is the same prefix every prior release used. Changing it signs the device out — keys are the session's only durable handle and nothing migrates between prefixes, so a new prefix reads an empty store and mints a fresh anonymous user. Pick one per environment before you ship and leave it alone.

If you supply a custom secureStorage adapter, treat the auth keys as one unit. The adapter receives the sensitive material — the refresh token, the access token, the cached user — while the access token's expiry stays in ordinary storage alongside the anonymous id. Namespacing only the keys that pass through your adapter splits a set that has to move together, and pairs one environment's token with another's deadline. Use storagePrefix instead: it covers both stores.

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.
last_credentialRefusing to unlink the only way into this account. It would still exist and still hold the user's data, with nothing able to sign in.No — add another credential first.
recent_auth_requiredThe credential is valid; the authentication behind it is too old to authorise a credential change. Re-authenticate and retry the SAME call — for Game Center and Play Games that re-assertion is silent.Yes — after re-authenticating, with the same input.
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

Signing in with a Game Center, Play Games or social identity that is already attached to an account is not a failure — it succeeds, and it signs you into that account. The question is what should happen to the guest account already on the device.

The default handles this for you, and you should normally leave it alone. It adopts the other account silently when nothing would be lost, and refuses when something would be:

On this deviceWhat happens
Fresh install, no guest sessionSigns in. Nothing to lose — and this is how a returning player gets their account back after a reinstall.
Live guest session with progressRejected with CREDENTIAL_IN_USE. Adopting would delete that guest account, so we ask you instead of guessing.

You only need onCredentialInUse to override that: "adopt" to switch accounts even when it discards guest progress, or "reject" to fail on any collision at all. Handling the default rejection looks like this:

try {
  // No policy needed — the default already refuses when this
  // device holds a guest account that adopting would delete.
  await sendora.auth.signInWithGameCenter({ link: true });
} 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,
        onCredentialInUse: "adopt",
      });
    }
  }
}
  • A rejection 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).
  • Passing "adopt" is the behaviour of every release before 3.14.0 — use it when the player has explicitly chosen to switch accounts.

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.

Changing a credential needs a recent sign-in

Seven routes refuse a token whose holder has not authenticated recently — linking an email, linking or unlinking a social or gaming identity, disabling MFA, and deleting a passkey. They fail with 403 RECENT_AUTH_REQUIRED and a WWW-Authenticate header. The window is five minutes.

The reason is that an access token is not a person. A stolen one used to convert into permanent ownership: link your own email address to someone else's account and you can sign in as them forever, long after the stolen token expired. Requiring a recent authentication means the hour an attacker has with a leaked token is not enough.

Handle it by re-authenticating, then retrying the call. For password and social accounts that is a normal sign-in. For Game Center and Play Games it is silent — your SDK's own re-sign-in produces the proof without showing the player anything.

try {
  await sendora.auth.linkEmail(email, password);
} catch (e) {
  if (e.code === "RECENT_AUTH_REQUIRED") {
    await sendora.auth.loginWithGameCenter();  // silent for gaming identities
    await sendora.auth.linkEmail(email, password);
  }
}

It is a 403, deliberately, and not the 401 that RFC 9470 suggests. Shipped SDKs treat a 401 on an auth call as a dead credential and several clear local identity on it — which for an account that began anonymous is destruction, not a sign-out. RFC 6750 already pairs a 403 with the same header for insufficient_scope.

Anonymous accounts are exempt. A guest attaching its first credential is a promotion, not a credential change: there is no prior owner to lock out, and whoever holds the guest's token already is the account. So the guest-to-identified upgrade — the one we tell you to prompt for — is never blocked by this.

Account deletion is never gated. App Store guideline 5.1.1(v) requires an in-app deletion path, and a proof a guest cannot produce would make that unshippable.

Expect a single unexplained refusal per device around the release that shipped this. Sessions created before it carry no record of when their holder authenticated. We treat unknown as not-recent rather than as acceptable, and we do not infer a value from the last token rotation — a refresh proves possession of a token, not the presence of a person, so inferring one would hand every live session a proof it never made. Those sessions fail once, the user signs in again, and the account carries a real timestamp from then on.

These methods used to report failure as success

⚠ Until the current SDK releases, four methods issued their request and discarded the response. No SDK transport throws on a non-2xx — each returns the envelope — so the discarded return value was the only place a refusal could ever have surfaced. They resolved cleanly no matter what the server said.

  • disableMfa() — step-up gated, so a refusal reported MFA as off while the second factor stayed armed server-side.
  • deletePasskey(id) — step-up gated, and it 404s on an id that is not yours. The row left the list while the credential stayed live on the account.
  • revokeSession(id) / revokeAllSessions() — a "sign out this device" button reporting success on a device that was never signed out.

All of them now surface the failure. On web and React Native the TypeScript signature is unchanged (Promise<void>) — what changed is that it can now reject, which it never did before. If you relied on these never throwing, add a catch — that reliance was on a bug.

iOS 5.0.0 is a breaking change. Three completion handlers move from () -> Void to (Result<Void, SendoraCloudAuthError>) -> Void, because the old signature had no failure channel at all. Swift will not compile the old call sites, which is deliberate. Android widens Unit to Result<Unit>, which Kotlin accepts without a change at the call site.

Removing a sign-in method

listLinkedIdentities() tells you what an account can sign in with; unlink(provider) removes one. Together they are a Connected Accounts screen.

const { identities, hasPassword } = await sendora.auth.listLinkedIdentities();
const credentials = identities.length + (hasPassword ? 1 : 0);

// Disable the control rather than letting the server refuse the tap.
const canDisconnect = credentials > 1;

await sendora.auth.unlink("google");

The last credential cannot be removed. Attempting it fails with LAST_CREDENTIAL / kind: "last_credential", and that refusal is enforced on our side rather than left to your app to remember. The reason is that the failure is not recoverable: an account with no credentials still exists and still holds the user's data, but nothing can ever authenticate into it again — there is no password to reset and no identity to present. Support cannot fix it without going into the database.

A password counts as a credential, so an account with email+password and one social identity may drop the social one. Compute the count as identities.length + (hasPassword ? 1 : 0) and grey out the control at 1 — the error exists as a backstop, not as the interaction.

Unlinking a provider that is not linked returns NOT_FOUND rather than quietly succeeding. A screen that says "disconnected" about a credential still attached to the account has told the user something untrue.

This matches Supabase, which requires at least two linked identities to unlink one. Firebase's unlink() has no such floor and will remove the last provider.

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. That replayed signed_in asserts a restored identity, not a verified live session — it fires as soon as a user has been read off disk, before anything has been round-tripped to the server. Do not build "we can reach your data" on it. Build that on the call you actually need: a null from getAccessToken() means no usable token right now, and signed_out / session_expired means the session is genuinely gone.
  • A failed sign-in emits nothing. No state changed, so there is no transition to report — the rejected promise is the whole story.
  • device_takeover fires on the Game Center and Play Games sign-ins too — and those are the ones where it matters, because that is where an adopted collision retires the anonymous account on the device. It is delivered on a call that succeeded, so this event (or the auth.device_takeover webhook) is your only signal that the returned user is a different sub from the one you had.

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.