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. (In the SDK releases after RN 1.45, web 3.29, iOS 5.9 and Android 4.32, a guest is held rather than revoked — see Held guests.)
  • If the device's secure storage cannot be read (a phone that is still locked when the app wakes in the background), those releases report the session as unknown with storageUnavailable, write nothing, and never start a new guest over it. A sign-in or ensureSession() waits up to 10 seconds, then fails with STORAGE_UNAVAILABLE (kind: "network", retryable). The session is read again when the device unlocks.
  • A refresh whose answer never arrived (a timeout, or a dropped connection) is retried with the same token within the server's 60-second grace window, so a lost response does not cost the session.

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:

SendoraCloud.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 password sign-ins on this account, from anywhere. retryAfterSeconds present = try again after it; absent = reset the password (or sign in another way) to continue.Only with retryAfterSeconds (then retryable is true). Without it, retryable is false — offer a password reset.
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. Also carries code SESSION_WITHHELD (the stored session belongs to a different project than expectedProjectId — every verb refuses until you call discardWithheld()) and PROJECT_MISMATCH (a fresh sign-in or mint answered for the wrong project; nothing was installed).No — fix it in the dashboard, or for SESSION_WITHHELD call discardWithheld() and sign in again.
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":
      showToast(`Try again in ${err.retryAfterSeconds ?? 60}s.`);
      break;
    case "account_locked":
      // No wait = the lock does not lift with time (retryable is false).
      showToast(err.retryAfterSeconds
        ? `Try again in ${err.retryAfterSeconds}s.`
        : "Reset your password, or sign in another way, to continue.");
      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 when someone has been guessing the password — every source counted together, so it can happen to an account whose owner did nothing wrong. After 100 failures in a row it arrives with no retryAfterSeconds at all: the absence is the signal, and a password reset, a magic link, an email code or a social sign-in clears it. The answer is identical whether or not the address has an account, and 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.

Keep the owner's own device signing in during an attack. A successful password sign-in (and an MFA completion) returns a knownDeviceToken next to the session. Store it with the session and send it back as knownDeviceToken on the next password sign-in from that device: while the account is under attack, requests carrying a valid one are still let through to the password check. It proves nothing on its own — the password is still checked — and it stops working when the password changes. Without it, a network the account signed in from successfully in the last 30 days is let through too, so an app on an older SDK keeps working from the player's usual connection.

curl -X POST https://api.sendoracloud.com/api/v1/auth-service/login \
  -H "x-api-key: pk_prod_..." \
  -H "Content-Type: application/json" \
  -d '{"email":"player@example.com","password":"correct-horse-battery","knownDeviceToken":"kdt1.<from the last sign-in>"}'

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.

Linking over a signed-in account attaches or fails typed — it never switches

Calling linkGameCenter() / linkPlayGames() (or signInWithGameCenter({ link: true }) on an SDK that routes it through the link endpoint) while already signed into an identified account has exactly three outcomes, and the server resolves who owns the credential before it asks for anything else:

  • Same identity, already on this account — a quiet 200 with alreadyLinked: true. No recent sign-in is required, nothing is written, and no other device is signed out. This is the launch-time "make sure Game Center is linked" call a returning player makes every day; it is free.
  • Owned by another account — 409 CREDENTIAL_IN_USE with details: { provider, collision: "identity" }. Nothing changes on either account, no recent sign-in is demanded first, and your other devices stay signed in. Offer the player "keep this account" or "switch"; a switch is a plain signInWithGameCenter(), never a link.
  • Genuinely new — the recent-sign-in check below applies, then the identity is attached and every other session of the account is revoked (a credential changed).

linkEmail() reports a taken address the same way: 409 CREDENTIAL_IN_USE with details: { provider: "password", collision: "email" }. Social and email links keep their original order (the single-use OAuth code or password is checked first) because a 403 after the exchange would burn the code.

A rejected link never signs anything out. A 409, a 422, or an alreadyLinked no-op leaves every other device of the account signed in; only a credential that is actually attached or removed revokes the account's other sessions. The four link endpoints are rate-limited at 30 calls per minute per device IP, the same budget as sign-in.

Changing a credential needs a recent sign-in

Every route that adds, changes or removes a credential refuses a token whose holder has not authenticated recently — linking an email, linking or unlinking a social or gaming identity, enrolling or disabling MFA, registering or deleting a passkey. They fail with 403 RECENT_AUTH_REQUIRED and a WWW-Authenticate header. The window is five minutes. Anonymous callers are exempt — a guest's first credential is a promotion, not a change — and for Game Center / Play Games the ownership check above runs first, so a same-identity or owned-elsewhere call is answered without ever asking for a recent sign-in.

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.linkEmailPassword(email, password);
} catch (e) {
  if (e.code === "RECENT_AUTH_REQUIRED") {
    await sendora.auth.signInWithGameCenter();  // silent for gaming identities
    await sendora.auth.linkEmailPassword(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.

An anonymous linkEmail(email, password) is a promotion, not a 403. Since ADR-033 wave 0 the link endpoint accepts a guest and runs exactly the gates signUp() / /auth-service/upgrade run — sign-ups allowed, the email free in this project, the password policy, email verification — then promotes the same account in place: the sub is preserved, the session is re-issued into the same family and the response carries { upgraded: true, user, tokens }, which the SDK installs. A taken address is 409 CREDENTIAL_IN_USE with { provider: "password", collision: "email" } and changes nothing. upgrade() keeps working unchanged.

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:

It is a transition stream, not a health signal. A transient failure of the background token refresh — offline, a timeout, HTTP 429 or 5xx — emits nothing: the stored refresh token is kept and the refresh retried with backoff, so no state changed. “No event” means “no confirmed transition”, never “the session is fine”. Only a server-rejected refresh token produces signed_out(session_expired). For liveness, read getAccessToken() — null means no usable token right now.

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.
  • The server now also says why it rejected a refresh. A 401 INVALID_REFRESH_TOKEN carries error.details.endReason ∈ expired · revoked (signed out elsewhere, or a credential change revoked this device) · reuse (replay detected, the family was revoked) · deleted (the session or account no longer exists) · unknown. evicted is reserved for the session cap and not yet emitted. Every SDK reports all of them as signed_out with reason session_expired, so the listener above catches a session revoked or an account deleted on another device. The server's detail is a separate field: change.endReason on React Native 1.41.1+ / web 3.24.1+, SignedOut.endReason on Android 4.26.1+, and auth.lastSignedOutEndReason() inside the listener on iOS 5.4.1+. It is absent after a deliberate sign-out and from older backends.
  • ⚠ RN 1.41.0, web 3.24.0, iOS 5.4.0 and Android 4.26.0 put that detail in the reason itself (revoked / deleted instead of session_expired), so a listener matching session_expired silently missed both cases. Upgrade to the next patch release. If you must run one of those four versions, treat every reason other than user and account_deleted as an involuntary sign-out.
  • 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.