Skip to content

Android Credential Manager reports SHA-1 mismatch, a cancelled concurrent request, AND real user dismissal all as USER_CANCELLED

On Android, androidx.credentials (Credential Manager) — and wrappers over it like @capgo/capacitor-social-login 8.4.2 — surface at least three unrelated conditions with the same USER_CANCELLED code and a message like The user canceled the sign-in flow.:

  1. A genuine user dismissal of the credential sheet.
  2. An OAuth client / SHA-1 certificate mismatch — the signing certificate fingerprint is not registered against the Android OAuth client in Google Cloud Console. Documented in @capgo/capacitor-social-login's Android troubleshooting README. This is a configuration outage: sign-in is 100% broken for everyone on that build, and it reports as if users are simply changing their minds.
  3. A second getCredentialAsync() while one is in flight — the first request is cancelled, and that cancellation is indistinguishable from a dismissal.

Why this bites

In an error tracker this reads as textbook noise: low volume, "the user did something", obviously not a crash. We had it queued as a one-line beforeSend suppression during a Sentry triage pass. It was withdrawn only because a human happened to know those specific events were a broken debug-keystore SHA-1. The filter would have permanently deleted the only alarm for broken mobile sign-in.

Generalisable rule: before suppressing any "the user did X" error, enumerate what else produces that exact code or message. SDKs that wrap a native credential/payment/permission flow are the usual offenders, because the native layer collapses distinct failures into one user-facing outcome.

What to do instead

Classify at the call site into a searchable tag, and name the ambiguity honestly rather than resolving it optimistically:

export function classifyGoogleSigninReason(
  code: string | number | undefined,
  message: string | undefined
): 'developer_console' | 'account_reauth' | 'no_credential' | 'user_cancelled_or_sha1' | 'other' {
  const msg = message ?? '';
  // Error 10 / 28444 — OAuth client misconfigured in the Google Cloud console
  if (msg.includes('Developer console') || msg.includes('28444') || msg.includes('10:')) return 'developer_console';
  if (msg.includes('Account reauth failed') || msg.includes('[16]')) return 'account_reauth';
  if (msg.includes('NoCredentialException') || msg.includes('Cannot find a matching credential')) return 'no_credential';
  // NOT called 'cancelled': Credential Manager reports SHA-1 mismatches this way too.
  if (String(code) === 'USER_CANCELLED') return 'user_cancelled_or_sha1';
  return 'other';
}

The tag name doing double duty (user_cancelled_or_sha1) is the point — it survives a later reader who greps for "cancelled" and decides it is noise.

Eliminate cause 3 outright so it stops polluting the bucket, with a module-level in-flight guard:

// Exactly one Credential Manager request at a time. A second getCredentialAsync()
// cancels the first, and that cancellation is indistinguishable from a real dismissal.
let signin_in_flight = false;

Then alert on rate, not presence. A steady trickle is users dismissing sheets; a step change is a certificate or client-id regression — most often after rotating a signing key, moving from debug to release signing, or adding a new build flavour whose fingerprint was never registered.

Cheap disambiguation signal

Tag the OAuth client's numeric GCP project prefix (the leading digits of the client id — public, and the only field that distinguishes prod from dev at a glance) alongside the app build. When user_cancelled_or_sha1 spikes, that tag usually tells you immediately that a build is pointed at the wrong client.

Separately, on Capacitor: the JS-side try/catch around the plugin call does not suppress the Sentry event, because @sentry/capacitor captures the native throw independently of the JS handler. Handling it in JS and still seeing it in Sentry is expected, not a bug in your error handling.

No signals yet