> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/security-observability/google-apple-sign-in-flutter.md).

# Google and Apple Sign-In in Flutter

Design a Google and Apple flow where Flutter only receives a provider credential, the backend verifies it, and only then issues an application session

Google and Apple do not issue the application's session. The two providers only authenticate the user and return a credential for the corresponding flow. Flutter sends that credential to the backend; the backend checks its signature, issuer, audience, expiry, and anti-replay binding before issuing an application session.

The invariant for this article is:

```
provider credential
        │
        ▼
backend verification
        │
        ▼
application session
```

Flutter may read state to control the UI, but it must not decode a provider JWT and then treat its email, role, or identity as trusted. Every sign-in, account-linking, and authorization decision still belongs to the backend.

## Result

After applying the design in this article, a sign-in attempt has one clear owner and crosses four boundaries:

```
User
  │ tap
  ▼
Flutter auth coordinator
  │ request an attempt before opening provider UI
  ▼
Google or Apple
  ├── cancel ───────────────► typed cancellation, no exchange call
  ├── failure ──────────────► safe typed reason
  └── provider credential
             │
             ▼
App backend
  ├── reject/retryable ─────► no application session yet
  ├── account link needed ──► explicit link challenge
  └── application session
             │
             ▼
atomic commit → load current user → initialize services → navigate once
```

The important result is not merely that sign-in works. It is that the following boundaries remain intact:

* The backend issues or preregisters an auth attempt before provider UI opens. The backend owns the expected binding, server expiry, and one-time/idempotency state.
* Flutter retains only an opaque attempt ID and the minimum secret representation required by the exact provider SDK.
* Cancellation, provider denial, provider unavailability, backend rejection, an account-link requirement, and transport failure are different outcomes.
* A Google credential cannot be paired with an Apple command, and vice versa.
* The session is committed only after backend verification; an old callback cannot overwrite a new session.
* Email is not a linking key. The backend uses a stable identity from the verified provider, commonly `issuer + subject`.
* App logout, local provider sign-out, unlink, and provider revocation are four separate operations.
* Telemetry receives no provider credential, authorization code, nonce, state, email, or unsanitized exception.

Artifacts with similar names have different purposes:

| Artifact                              | Issuer       | Used by                                                  | Application session?                |
| ------------------------------------- | ------------ | -------------------------------------------------------- | ----------------------------------- |
| ID token                              | Google/Apple | Backend verifies the authentication event and subject    | No                                  |
| Authorization code                    | Provider     | Backend exchanges it once under the provider contract    | No                                  |
| Provider access token                 | Provider     | Calls a provider resource API when the scope requires it | No                                  |
| Application access/refresh credential | App backend  | Calls the application's API                              | Yes, under the app's session policy |

### What was verified from source?

* **\[Source verified]** Google and Apple both have button-to-SDK/backend call sites. They are active source paths, not merely entries in a dependency lock.
* **\[Source verified]** The provider credential and application token are different artifacts in the call chain. Session initialization runs after the backend response.
* **\[Source verified]** The Google path uses SDK sign-in, reads an ID token, and then calls the backend. The Apple path receives multiple fields from the plugin, but the implementation inspected only uses the identity token and decodes email on the client.
* **\[Source verified]** The current logout path resets application state and app-scoped services, while Google local sign-out sits in the provider-attempt path. No Apple unlink or revocation call site was found in the scoped scan.
* **\[Configured only]** Android and iOS contain provider-related Gradle, manifest, plist, URL scheme, and entitlement configuration. Static configuration does not prove that the provider console, signed artifact, or release sign-in is correct.
* **\[Proposed design]** The typed contracts, backend-owned attempt, exact binding adapter, session generation, and explicit linking below are a public architecture hardened from the findings; they are not a claim that the reference app already implements all of them.
* **\[Test evidence]** No focused Google or Apple tests were found in the scoped scan. The focused auth test command stopped during dependency resolution before tests ran.
* **\[Runtime/backend/provider-console/device unknown]** There is no device trace, backend source, replay-store evidence, provider-console inspection, or dashboard-delivery evidence.

The dependency snapshot used to preserve the version boundary is `google_sign_in 6.3.0` and `sign_in_with_apple 7.0.1`. This article does not mix Google 7.x APIs into the 6.x sample.

## Problem

### Provider success does not mean the app is signed in

SDK success only means that the provider returned a credential. Flutter still does not know whether that credential has the correct issuer, targets the correct audience, remains valid, is bound to the correct request, or has been replayed.

Client-side JWT parsing only reads an encoded string. It does not verify the signature or trust chain. Code that decodes an email and then creates a local session therefore places the security decision on the wrong side of the boundary.

Only the backend can:

1. Use trusted provider metadata and public keys.
2. Exact-match the issuer and audience allowlist for the platform/flavor.
3. Check the signature, expiry, issued-at policy, and required claims.
4. Check nonce/state against a server-owned binding when the flow applies.
5. Consume an authorization code or attempt exactly once.
6. Resolve the account through a stable provider identity.
7. Apply account status, linking, and risk policy.
8. Issue the application's own session.

### Client-generated expected and returned values do not prevent replay

Suppose Flutter generates `state` or `nonce` and later sends both the “expected value” and provider result to the backend. The backend has no independent binding that proves the expected value was registered before the request. Comparing two values supplied by the same client does not create a server-side anti-replay guarantee.

The backend must issue or preregister the auth attempt **before** provider UI opens. The server record must bind at least:

| Server-owned field                  | Purpose                                                         |
| ----------------------------------- | --------------------------------------------------------------- |
| Opaque attempt ID                   | Correlate the request without exposing the binding              |
| Provider + platform + intent        | Prevent the provider or purpose from changing mid-flow          |
| Expected/hash verification material | Verify returned state or a nonce claim under the exact contract |
| Server expiry                       | Avoid reliance on the client clock                              |
| One-time/idempotency status         | Prevent replay while allowing a safe retry after network loss   |

The `expiresAt` returned to Flutter is only a UX hint for disabling the button or requesting a new attempt. The backend does not use the client clock as a security input.

### `state`, `nonce`, and representation are not one concept

`state` binds a redirect request to its callback. `nonce` binds an authentication request to its ID token. A flow may need one or both, but they do not replace each other.

The raw expected value on the backend is also not necessarily the same as the argument or claim representation required by every SDK. Depending on the exact provider/platform contract, an adapter may need to pass a raw value, digest, or defined encoding. Hardcoding one transform for Android, iOS, and web produces code that looks secure while verifying the wrong contract.

For Apple, the adapter only passes the representation supplied by the backend-issued attempt for the exact SDK flow. The backend retains the expected binding and applies the corresponding official verification rule. The raw binding does not enter generic Redux state, persistence, analytics, or logs.

### The Google 6.x assurance gap needs an accurate name

The exact `google_sign_in 6.3.0` mobile API in the source does not expose custom `state` or `nonce` parameters on `signIn()`. The sample must not invent them.

An opaque attempt ID is still useful for:

* making one exchange single-flight;
* discarding an old result after cancellation or logout;
* retrying idempotently after a response is lost;
* binding provider, platform, and intent at the app-backend boundary.

However, that attempt ID **does not create OIDC token binding** for the legacy Google path. The backend must still fully validate the ID token. If the threat model requires stronger nonce or authorization-code binding, choose an official modern flow/API that exposes that artifact and research its version-specific contract again.

### A generic request allows the wrong provider–credential pair

A request with independent `provider` and `credential` fields can represent a meaningless state: an Apple provider paired with a Google credential. If the serializer accepts an untyped key-value structure and casts at runtime, the error appears late, possibly after the request has left the device.

A stronger contract makes the wrong pair fail to compile. The provider is derived from a provider-specific command, and the serializer uses an exhaustive switch. Adding a provider forces the analyzer to require a corresponding projection.

### Cancellation, failure, and stale callbacks have different semantics

A provider SDK commonly has several results:

* The user closes the sheet or returns to the app.
* The provider is unavailable on the current OS/device.
* The user denies authorization.
* The credential is missing a required field.
* The SDK or transport fails.
* The provider credential is valid but the backend rejects it.
* The backend requires registration input or an account-link challenge.

Collapsing all of them into an empty string or one exception makes it hard for the UI to decide whether to show a toast, retry, or call the backend. If a double tap creates two Futures, or a callback arrives after logout, an old session may also be committed late.

### Account discovery is not account linking

Email can change, is not a provider subject, and may be a relay address. Even if the provider says the email was verified, it still does not prove that the user owns an existing app account.

Automatic email-based linking creates an account-takeover risk. Linking must be an explicit backend transaction with a current authenticated session or step-up proof, explicit consent, a short-lived challenge, and a uniqueness constraint on the verified provider identity.

Apple name and email fields are also one-time data: they may appear only on the first authorization. A missing name on a later sign-in is not an authentication failure. When present, the client sends those fields in the same verified exchange; the backend persists them under its data policy only after verifying the credential.

### Logout does not imply revocation

The following operations have different effects:

| Operation               | Primary effect                                                                 | What must not be claimed                                   |
| ----------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------- |
| App logout              | Clears the application session and app-scoped identity/state                   | Does not sign out of or revoke the provider by default     |
| Local provider sign-out | Changes SDK session/account chooser behavior on the device                     | Does not revoke the provider grant                         |
| Unlink                  | Removes a provider identity from the app account through a backend transaction | Must not leave the account without a final recovery method |
| Provider revocation     | Revokes a grant/token under the provider contract                              | Is not the default operation for every logout              |

### Static configuration and call sites are not runtime evidence

A provider file, URL scheme, callback activity, or entitlement only proves that the code is configured. Runtime behavior still depends on the provider console, signing certificate, application/bundle registration, provisioning profile, release flavor, OS availability, and callback behavior.

Likewise, a method named `verify` does not prove that the backend checked signature, issuer, audience, and nonce. An analytics call site does not prove that an event reached the dashboard. Every claim must retain the correct evidence level.

## Solution

### 1. Start with typed provider outcomes and credentials

The UI only emits intent. A provider adapter returns a typed outcome, not an empty string, an untyped key-value structure, or an unsanitized exception.

```dart
enum IdentityProvider { google, apple }

enum SafeProviderReason {
  invalidCredential,
  providerDenied,
  temporarilyUnavailable,
  unexpectedAdapterFailure,
}

abstract interface class SecretString {}

sealed class ProviderCredential {
  const ProviderCredential({required this.attemptId});

  final String attemptId;
}

final class GoogleIdentityCredential extends ProviderCredential {
  const GoogleIdentityCredential({
    required super.attemptId,
    required this.idToken,
  });

  final SecretString idToken;
}

final class AppleIdentityCredential extends ProviderCredential {
  const AppleIdentityCredential({
    required super.attemptId,
    required this.idToken,
    required this.authorizationCode,
    required this.returnedState,
    required this.firstAuthorizationProfile,
  });

  final SecretString idToken;
  final SecretString authorizationCode;
  final SecretString? returnedState;
  final OneTimeAppleProfile? firstAuthorizationProfile;
}

sealed class ProviderAuthOutcome<C extends ProviderCredential> {
  const ProviderAuthOutcome();
}

final class ProviderAuthorized<C extends ProviderCredential>
    extends ProviderAuthOutcome<C> {
  const ProviderAuthorized(this.credential);

  final C credential;
}

final class ProviderCancelled<C extends ProviderCredential>
    extends ProviderAuthOutcome<C> {
  const ProviderCancelled();
}

final class ProviderUnavailable<C extends ProviderCredential>
    extends ProviderAuthOutcome<C> {
  const ProviderUnavailable();
}

final class ProviderFailed<C extends ProviderCredential>
    extends ProviderAuthOutcome<C> {
  const ProviderFailed(this.reason);

  final SafeProviderReason reason;
}
```

Here, `SecretString` is a redacted wrapper that crosses a dedicated serializer/provider boundary. It does not expose a raw value through `toString()`, generic JSON, debug state, or telemetry.

Cancellation does not call the backend exchange and usually needs no error toast. Failure carries only an allowlisted reason; sensitive diagnostics are scrubbed at ingress before reaching the error boundary.

### 2. Request a backend-issued attempt before provider UI

The client attempt is a typed response. It is not a copy of the server record.

```dart
enum AuthIntent { signInOrRegister, linkCurrentAccount }

sealed class ClientAuthAttempt {
  const ClientAuthAttempt({
    required this.id,
    required this.intent,
    required this.clientExpiresAtHint,
  });

  final String id;
  final AuthIntent intent;
  final DateTime clientExpiresAtHint;
}

final class GoogleLegacyClientAttempt extends ClientAuthAttempt {
  const GoogleLegacyClientAttempt({
    required super.id,
    required super.intent,
    required super.clientExpiresAtHint,
  });
}

final class AppleClientAttempt extends ClientAuthAttempt {
  const AppleClientAttempt({
    required super.id,
    required super.intent,
    required super.clientExpiresAtHint,
    required this.sdkBinding,
  });

  final AppleSdkBindingArguments sdkBinding;
}

final class AppleSdkBindingArguments {
  const AppleSdkBindingArguments({
    this.stateArgument,
    this.nonceArgument,
  });

  final SecretString? stateArgument;
  final SecretString? nonceArgument;
}
```

The proposed protocol is:

1. The coordinator asks the backend to begin an attempt with provider, platform, and intent.
2. The backend generates the binding with a CSPRNG and stores expected/hash verification material, server expiry, and the `issued` state.
3. The backend returns an opaque ID, a client expiry hint, and the provider-facing arguments required by the exact flow.
4. The coordinator opens provider UI exactly once.
5. The adapter passes the exact arguments to the SDK without applying one universal transform.
6. The backend receives the provider credential and attempt ID, loads the server record, verifies the binding, and consumes or idempotently completes the attempt.

Do not persist `AppleSdkBindingArguments` in generic client state. If the app must survive process death during a redirect flow, design a separate recovery contract with encrypted short-lived storage, server lookup, expiry, and one-time semantics; do not serialize the raw secret into persisted Redux state.

### 3. Keep each adapter faithful to its provider contract

The Apple adapter receives exact SDK arguments from the attempt. The SDK access layer owns the transition from the secret wrapper into the provider API; the application layer never sees the raw representation. The SDK boundary returns a sealed outcome for every branch instead of using throws as its public contract.

```dart
sealed class SdkAuthOutcome<R> {
  const SdkAuthOutcome();
}

final class SdkAuthorized<R> extends SdkAuthOutcome<R> {
  const SdkAuthorized(this.value);

  final R value;
}

final class SdkCancelled<R> extends SdkAuthOutcome<R> {
  const SdkCancelled();
}

final class SdkUnavailable<R> extends SdkAuthOutcome<R> {
  const SdkUnavailable();
}

final class SdkFailed<R> extends SdkAuthOutcome<R> {
  const SdkFailed(this.reason);

  final SafeProviderReason reason;
}

abstract interface class SanitizedAdapterGuard {
  Future<ProviderAuthOutcome<C>> run<C extends ProviderCredential>({
    required IdentityProvider provider,
    required Future<ProviderAuthOutcome<C>> Function() operation,
  });
}

abstract interface class AppleSdkGateway {
  Future<SdkAuthOutcome<AppleSdkResult>> authorize({
    required AppleSdkBindingArguments binding,
  });
}

final class AppleSdkResult {
  const AppleSdkResult({
    required this.idToken,
    required this.authorizationCode,
    required this.returnedState,
    required this.firstAuthorizationProfile,
  });

  final SecretString idToken;
  final SecretString authorizationCode;
  final SecretString? returnedState;
  final OneTimeAppleProfile? firstAuthorizationProfile;
}

final class AppleProviderAdapter {
  const AppleProviderAdapter(this.sdk, this.guard);

  final AppleSdkGateway sdk;
  final SanitizedAdapterGuard guard;

  Future<ProviderAuthOutcome<AppleIdentityCredential>> authorize(
    AppleClientAttempt attempt,
  ) =>
      guard.run(
        provider: IdentityProvider.apple,
        operation: () async {
          final outcome = await sdk.authorize(binding: attempt.sdkBinding);

          return switch (outcome) {
            SdkAuthorized(:final value) => ProviderAuthorized(
              AppleIdentityCredential(
                attemptId: attempt.id,
                idToken: value.idToken,
                authorizationCode: value.authorizationCode,
                returnedState: value.returnedState,
                firstAuthorizationProfile: value.firstAuthorizationProfile,
              ),
            ),
            SdkCancelled() => const ProviderCancelled(),
            SdkUnavailable() => const ProviderUnavailable(),
            SdkFailed(:final reason) => ProviderFailed(reason),
          };
        },
      );
}
```

The concrete `AppleSdkGateway` implementation converts cancellation, unavailability, and known SDK failures into `SdkAuthOutcome`. `SanitizedAdapterGuard` wraps both the SDK invocation and mapper: if the vendor gateway or mapper unexpectedly throws, the guard applies the OBS-01 scrubbing policy and returns `ProviderFailed(SafeProviderReason.unexpectedAdapterFailure)`. The exception instance, message, credential, and SDK result never leave the boundary or enter state/telemetry.

Google 6.x uses the same typed boundary. The concrete gateway converts the legacy SDK's nullable account into `SdkCancelled`; a missing ID token remains a typed invalid credential. The adapter receives no invented nonce/state.

```dart
abstract interface class GoogleLegacySdkGateway {
  Future<SdkAuthOutcome<GoogleLegacySdkResult>> signIn();
}

final class GoogleLegacySdkResult {
  const GoogleLegacySdkResult({required this.idToken});

  final SecretString? idToken;
}

final class GoogleLegacyProviderAdapter {
  const GoogleLegacyProviderAdapter(this.sdk, this.guard);

  final GoogleLegacySdkGateway sdk;
  final SanitizedAdapterGuard guard;

  Future<ProviderAuthOutcome<GoogleIdentityCredential>> authorize(
    GoogleLegacyClientAttempt attempt,
  ) =>
      guard.run(
        provider: IdentityProvider.google,
        operation: () async {
          final outcome = await sdk.signIn();

          return switch (outcome) {
            SdkAuthorized(:final value) when value.idToken != null =>
              ProviderAuthorized(
                GoogleIdentityCredential(
                  attemptId: attempt.id,
                  idToken: value.idToken!,
                ),
              ),
            SdkAuthorized() => const ProviderFailed(
              SafeProviderReason.invalidCredential,
            ),
            SdkCancelled() => const ProviderCancelled(),
            SdkUnavailable() => const ProviderUnavailable(),
            SdkFailed(:final reason) => ProviderFailed(reason),
          };
        },
      );
}
```

Known provider denial/failure is sanitized into `SafeProviderReason` before leaving the SDK gateway. The guard is the final fail-closed layer: a mapper throw cannot become authorized, cannot trigger a backend exchange, and emits only a sanitized operational signal under the Sentry article's policy.

Do not place provider sign-out in the `finally` block of every sign-in attempt. Account chooser behavior is a separate product policy; cancellation or SDK failure does not require local sign-out by default.

### 4. Lock the provider–credential pair with a sealed command

The command does not accept a provider enum from its caller. The provider is derived from the subtype, so the wrong pair cannot be represented.

```dart
sealed class FederatedSessionCommand {
  const FederatedSessionCommand({
    required this.intent,
    this.attribution,
  });

  final AuthIntent intent;
  final RegistrationAttribution? attribution;
  String get attemptId;
  IdentityProvider get provider;
}

final class ExchangeGoogleSession extends FederatedSessionCommand {
  const ExchangeGoogleSession({
    required super.intent,
    required this.credential,
    super.attribution,
  });

  final GoogleIdentityCredential credential;

  @override
  String get attemptId => credential.attemptId;

  @override
  IdentityProvider get provider => IdentityProvider.google;
}

final class ExchangeAppleSession extends FederatedSessionCommand {
  const ExchangeAppleSession({
    required super.intent,
    required this.credential,
    super.attribution,
  });

  final AppleIdentityCredential credential;

  @override
  String get attemptId => credential.attemptId;

  @override
  IdentityProvider get provider => IdentityProvider.apple;
}

ProviderHttpRequest projectForBackend(FederatedSessionCommand command) =>
    switch (command) {
      ExchangeGoogleSession(
        :final credential,
        :final intent,
        :final attribution,
      ) =>
        GoogleSessionHttpRequest.fromParts(
          credential: credential,
          intent: intent,
          attribution: attribution,
        ),
      ExchangeAppleSession(
        :final credential,
        :final intent,
        :final attribution,
      ) =>
        AppleSessionHttpRequest.fromParts(
          credential: credential,
          intent: intent,
          attribution: attribution,
        ),
    };
```

The projection has no `default`, unsafe cast, or generic JSON identity body. Adding a subtype makes the switch non-exhaustive and forces the developer to define a new HTTP contract.

Before network I/O, the gateway compares the command subtype with the typed attempt held by the coordinator. The backend independently loads the server-owned attempt and rechecks provider, platform, and intent before credential verification.

### 5. Make backend validation fail closed

Mobile code does not prove that the backend performs the following steps. This is **\[Proposed design]** and an acceptance contract that needs backend tests:

1. Load the server-owned attempt; reject it if it does not exist, has the wrong provider/platform/intent, is expired by the server clock, or has already been consumed.
2. Resolve trusted provider metadata/keys and support key rotation.
3. Accept only allowlisted algorithms; do not let the token select its verification path.
4. Exact-match the trusted issuer.
5. Check the audience allowlist for the platform/flavor; handle the authorized party under OIDC/provider rules when multiple audiences exist.
6. Check the signature, expiry, issued-at policy, and required claims.
7. For a flow with a nonce, verify the claim against the server-owned expected binding and exact representation rule.
8. For a redirect flow, verify returned state against the server-owned expected binding before code/credential exchange.
9. Exchange the authorization code server-to-server using the correct client type/redirect contract, and never reuse it.
10. Use `issuer + subject` as the provider identity key; email is only a mutable attribute.
11. Apply registration, linking, and account policy before issuing an application session.
12. Atomically consume the attempt with the session/link result; retrying the same idempotency key must not create a second account or link.
13. Return a safe machine-readable reason; do not echo provider credentials, claim sets, or upstream responses.

The client does not send expected nonce/state values for the backend to trust. The Apple verifier and adapter must agree on the exact flow contract, but the server still owns the expected binding. When the legacy Google path has no nonce, the checklist records the assurance gap instead of pretending to verify a nonexistent claim.

### 6. Give one coordinator sole ownership of session commits

The coordinator retains the active attempt and session generation. A widget does not call the SDK, backend, persistence, and navigator from four separate callbacks.

```dart
sealed class AuthFlowState {
  const AuthFlowState();
}

final class AuthIdle extends AuthFlowState {
  const AuthIdle();
}

final class AwaitingProvider extends AuthFlowState {
  const AwaitingProvider(this.attemptId);

  final String attemptId;
}

final class ExchangingCredential extends AuthFlowState {
  const ExchangingCredential(this.attemptId);

  final String attemptId;
}

final class InitializingSession extends AuthFlowState {
  const InitializingSession(this.generation);

  final int generation;
}

final class AuthReady extends AuthFlowState {
  const AuthReady(this.generation);

  final int generation;
}
```

The transition policy is:

* Only one provider attempt runs at a time; a later tap is ignored or shares the same in-flight Future.
* Cancellation returns to idle and does not call the exchange.
* Every callback checks the opaque attempt ID; an old result after cancellation, logout, or disposal is dropped.
* The backend session bundle is written atomically, then the generation advances.
* Current-user and app-scoped-service initialization captures the generation; a response from an old generation cannot mutate the new session.
* Navigation has one owner and runs once only when the current generation reaches `AuthReady`.
* Analytics failure does not roll back the session; secure-session write failure does not transition to ready.
* Logout during an exchange invalidates the attempt/generation so a late response cannot resurrect the session.

The application credential after verification belongs to the Secure Storage boundary, not the provider adapter:

{% content-ref url="/pages/Fbl8YWIUovHWvjb7NUlz" %}
[Secure Storage: Keychain and Keystore](/flutter/my-flutter/security-observability/secure-storage-keychain-keystore.md)
{% endcontent-ref %}

The HTTP boundary must preserve auth generation so a `401` from an old request cannot log out a new session:

{% content-ref url="/pages/r1BG7cXgqoay4tvlyEeO" %}
[Production HTTP Client for Flutter](/flutter/my-flutter/systems-realtime/production-http-client.md)
{% endcontent-ref %}

### 7. Make account linking a separate transaction

```
verified provider identity
          │
          ▼
backend resolves issuer + subject
  ├── linked to current account ─────► idempotent success
  ├── linked to another account ─────► safe rejection + recovery
  ├── anonymous, no link ────────────► register or existing-account challenge
  └── current session requests link ─► step-up + consent + atomic link
```

The link challenge must be short-lived, single-use, and bound to the current session generation, provider, attempt, and target account. A database uniqueness constraint ensures that one `issuer + subject` cannot belong to two accounts.

Unlinking must verify that the account retains at least one valid sign-in/recovery method. The UI should not expose enough detail for account enumeration. An Apple relay email is a contact attribute, not a signal to merge accounts.

### 8. Separate logout, sign-out, unlink, and revocation

A practical policy can be:

1. Normal app logout atomically clears application credentials/state, cancels app-scoped subscriptions, and advances the generation.
2. Clear analytics and Sentry identity before another session becomes active.
3. Local provider sign-out is a UX choice that forces an account chooser, not evidence of revocation.
4. Unlink runs through an explicit backend transaction with a recovery guard.
5. Revocation uses a separate provider/server contract, usually for unlink or account deletion.

Revocation can fail or require retry. The app must not claim that an account was revoked merely because local SDK sign-out succeeded. For Apple, token revocation and server notifications belong to the backend/provider boundary; mobile source alone does not prove that this lifecycle is running.

### 9. Keep attribution and observability outside the trust boundary

Attribution may accompany a registration request as optional context, but it is never identity proof. The backend auth result must not depend on analytics delivery succeeding.

Auth telemetry should receive only allowlisted fields such as:

* provider enum;
* stage enum;
* typed reason code;
* platform;
* duration bucket;
* a short-scoped anonymous correlation ID when policy allows it.

Do not send provider credentials, authorization codes, state, nonce, email, provider subject, callback URLs, account/device identifiers, SDK results, or upstream responses. Sentry receives a sanitized reason at the error boundary; analytics receives a typed event catalog. Identity is set only after session initialization and must be cleared on logout.

{% content-ref url="/pages/ATOHUDA9JCzn2wmCbZD9" %}
[Sentry for Crashes and Tracing](/flutter/my-flutter/security-observability/sentry-flutter-crash-http-navigation-tracing.md)
{% endcontent-ref %}

{% content-ref url="/pages/7FxGn5quNwHeyTJDP6IJ" %}
[Multi-Provider Analytics in Flutter](/flutter/my-flutter/security-observability/multi-provider-analytics-flutter.md)
{% endcontent-ref %}

The Apple browser callback on Android is an auth response, not a generic navigation authority. The callback adapter only delivers a typed result to the auth coordinator; it does not replace the session or open an arbitrary route.

{% content-ref url="/pages/XuzrnRXJWWpiiB1XhH1m" %}
[Multi-Source Deep Links in Flutter](/flutter/my-flutter/systems-realtime/deep-link-app-links-adjust-onesignal.md)
{% endcontent-ref %}

### 10. Configure Android and iOS per build

**\[Configured only]** The source snapshot contains Google-related configuration on Android/iOS, an Apple callback activity on Android, and Sign in with Apple entitlements in several iOS configurations. These are checklist inputs, not a runtime pass.

On Android, verify:

* Every application flavor maps to the correct provider registration and release signing certificate.
* Release builds are checked separately; debug success does not prove production success.
* Apple web return configuration exact-matches the destination and binds state; the callback must not forward an arbitrary URI.
* The callback activity accepts only the expected flow and does not replace backend verification.
* Browser, Google Play Services, and unsupported-device behavior are tested for the supported market.

On iOS, verify:

* Every scheme/build configuration uses the correct provider configuration and callback scheme.
* Sign in with Apple capability exists on the correct target, App ID, entitlement, and provisioning profile.
* Availability is checked before rendering or starting the flow; unsupported OS versions have a fallback.
* The signed archive/TestFlight entitlement is inspected, not merely the source file.
* First authorization, later authorization, Hide My Email, account switching, and revoked credentials all have device cases.

Do not put a client ID, redirect URL, bundle/application ID, Team/Service ID, signing fingerprint, or provider-console screenshot in documentation or fixtures.

### 11. Test the contract, not only the happy path

#### Provider adapter and type safety

| Case                           | Assertion                                                                                            |
| ------------------------------ | ---------------------------------------------------------------------------------------------------- |
| User cancellation              | Typed cancellation; no backend call and no error toast                                               |
| Provider unavailable           | Typed unavailable; UI fallback and no backend call                                                   |
| Missing ID token               | Typed invalid credential; no exchange                                                                |
| Missing Apple one-time profile | Still authorized when required credentials are valid                                                 |
| Known SDK failure              | Typed safe reason; no backend call                                                                   |
| Vendor SDK exception           | Only crosses the sanitized OBS-01 boundary; instance/message never enters state/telemetry            |
| Adapter mapper throw           | Becomes typed unexpected failure, never authorized, makes no backend call, and exposes no diagnostic |
| Double tap                     | One SDK invocation or the same in-flight result                                                      |
| Late result                    | No backend exchange or navigation                                                                    |
| Wrong provider–credential pair | The analyzer rejects the invalid constructor                                                         |
| New command subtype            | The exhaustive switch requires a new projection                                                      |
| Generic/unsafe serializer      | Rejected by review/test; no independent provider field                                               |

#### Auth coordinator

1. A backend-issued attempt exists before provider UI opens.
2. The client expiry hint affects only UX; client clock drift cannot make the server accept an expired attempt.
3. Cancellation preserves anonymous state and makes no exchange call.
4. Provider success exchanges exactly once against the current attempt.
5. An old attempt ID or generation is dropped.
6. Backend rejection or secure-write failure does not transition to `AuthReady`.
7. An old initialization response cannot overwrite a new session.
8. Navigation runs exactly once after the current generation is ready.
9. Logout during exchange invalidates a late response.
10. Analytics/Sentry failure does not corrupt auth state or retain a secret for retry.

#### Backend contract/security

| Case                                              | Required result                                   |
| ------------------------------------------------- | ------------------------------------------------- |
| Invalid signature/key/issuer                      | Fail-closed rejection                             |
| Invalid audience or authorized party              | Reject                                            |
| Expiry/issued-at outside policy                   | Reject                                            |
| Client says attempt is valid after server expiry  | Reject                                            |
| Client sends expected binding with returned value | Do not trust it; use only the server-owned record |
| Missing nonce/state or wrong representation       | Reject under the exact flow rule                  |
| Provider/platform/intent mismatch                 | Reject before credential verification             |
| Attempt/code used twice                           | Replay rejection or an idempotent safe result     |
| Retry after network loss                          | Do not create two accounts, sessions, or links    |
| Legacy Google retry                               | Idempotent, but no OIDC nonce-binding claim       |
| Same email, new subject                           | Do not auto-link                                  |
| Link without current session/step-up              | Reject                                            |
| Concurrent link for one subject                   | The uniqueness constraint allows one winner       |
| Unlink final method                               | Reject or require a recovery method first         |
| Upstream diagnostic contains a secret             | Scrub before logs/responses                       |

Backend tests use a synthetic issuer/fake verifier and synthetic credentials. Never place a real provider credential in a fixture.

#### Minimum device matrix

| Scenario                         | Android                                        | iOS                                            |
| -------------------------------- | ---------------------------------------------- | ---------------------------------------------- |
| Google signed release-like build | Provider UI → backend session → one navigation | Provider UI → backend session → one navigation |
| Apple happy path                 | Browser callback + binding                     | Native sheet + binding                         |
| Cancel/background/resume         | No duplicate exchange                          | No duplicate exchange                          |
| Offline after provider success   | Retry the same idempotent attempt              | Retry the same idempotent attempt              |
| Account chooser/switch           | Do not carry the old session                   | Do not carry the old session                   |
| Apple first/later authorization  | Handle the one-time profile correctly          | Handle the one-time profile correctly          |
| Revoked credential               | Re-authenticate/anonymous policy               | Re-authenticate/anonymous policy               |
| Logout/sign-out/revoke           | Separate outcomes                              | Separate outcomes                              |

### 12. Versions, evidence, and limits

* Flutter source snapshot: `3.41.2`.
* Dart constraint: from `3.11` to before `4.0`.
* `google_sign_in`: exact `6.3.0`.
* `sign_in_with_apple`: exact `7.0.1`.
* Platform: Android and iOS.

**\[Test evidence]** The scoped search found no direct unit, widget, or integration tests for the two providers. Existing auth tests cover only part of auth state and refresh/init behavior. The focused command was attempted, but dependency resolution was blocked by private Git host verification, so `0` test assertions executed; this is not an auth test failure.

**\[Runtime/backend/provider-console/device unknown]** Provider UI was not run on an emulator/device; callback ordering, signed release/TestFlight artifacts, the provider console, backend verifier, replay cache, database uniqueness, revocation notifications, and analytics/Sentry dashboards were not inspected.

The article therefore concludes only what the observed source path and configuration support. It does not promote dependency/configuration to runtime support, a method name to cryptographic verification, or client SDK acceptance to dashboard delivery.

### 13. References

* [`google_sign_in 6.3.0`](https://pub.dev/packages/google_sign_in/versions/6.3.0)
* [`sign_in_with_apple 7.0.1`](https://pub.dev/packages/sign_in_with_apple/versions/7.0.1)
* [Google — Verify the Google ID token on your server side](https://developers.google.com/identity/gsi/web/guides/verify-google-id-token)
* [Google — OpenID Connect](https://developers.google.com/identity/openid-connect/openid-connect)
* [Apple — Authenticating users with Sign in with Apple](https://developer.apple.com/documentation/signinwithapple/authenticating-users-with-sign-in-with-apple)
* [Apple — Configuring your webpage for Sign in with Apple](https://developer.apple.com/documentation/signinwithapple/configuring-your-webpage-for-sign-in-with-apple)
* [Apple — Configuring Sign in with Apple support](https://developer.apple.com/documentation/xcode/configuring-sign-in-with-apple)
* [Apple — Revoke tokens](https://developer.apple.com/documentation/signinwithapplerestapi/revoke-tokens)
* [Apple — Processing account changes](https://developer.apple.com/documentation/signinwithapple/processing-changes-for-sign-in-with-apple-accounts)
* [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0-18.html)
* [RFC 8252 — OAuth 2.0 for Native Apps](https://www.rfc-editor.org/rfc/rfc8252)

## Conclusion

Google and Apple provide only a provider credential. The backend must verify the credential together with the server-owned attempt before issuing an application session; Flutter must not turn a decoded JWT into a trusted identity.

A backend-issued or preregistered attempt places the expected binding, expiry, and replay state at the correct trust boundary. The Apple adapter passes the exact representation required by the SDK flow; the legacy Google 6.x path describes its assurance gap accurately. A sealed provider-specific command eliminates wrong pairs in the type system, while session generation prevents an old callback from overwriting a new session.

Account linking relies on a verified subject and explicit step-up, not email. App logout, local provider sign-out, unlink, and revocation have separate lifecycles. A configuration file, call site, or test source still does not replace backend, provider-console, and device evidence.

The goal is not to hide every provider difference behind a `String token`. The goal is to retain those differences in typed adapters, centralize session ownership in one coordinator, and transition to authenticated only after backend verification.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
