---
title: "Show the Last Used Login Method"
url: "https://effect-auth.itsbroly.com/recipes/show-last-used-login-method/"
description: "Mark the login method that last completed authentication on this browser."
---



A "Last used" badge is an app-owned device hint, not authentication state. Store a small allow-listed method only after the final `authenticated` result; never update it when a flow starts, fails, or returns a continuation. The recommended `localStorage` design needs no effect-auth extension, Cloudflare Worker route, D1 migration, binding, or Alchemy resource.

```mermaid title="Record only completed authentication"
flowchart LR
  A[Choose password, passkey, or provider] --> B[Run authentication]
  B -->|authenticated| C[Store allow-listed method]
  B -->|continuation| D[Carry primary method with flow]
  D -->|authenticated| C
  B -->|failure or cancellation| E[Keep previous hint]
  C --> F[Show badge on next sign-in]
```

| Method     | Record after                                                      | Do not record after                                |
| ---------- | ----------------------------------------------------------------- | -------------------------------------------------- |
| Password   | `password.signIn` returns `authenticated`                         | Form submit or `requires_mfa`                      |
| Email OTP  | `emailOtp.verify` returns `authenticated`                         | Sending the code                                   |
| Magic link | `magicLink.verify` returns `authenticated`                        | Sending the link or navigating before verification |
| Passkey    | `passkey.signIn` returns `authenticated`                          | Starting WebAuthn or any continuation              |
| OAuth      | The app-owned callback completes `AuthFlow` and commits a session | Provider button click or authorization redirect    |

## Store an allow-listed hint [#store-an-allow-listed-hint]

Keep provider IDs explicit so stale, malformed, or injected values cannot select arbitrary UI. Browser storage can be unavailable, so the hint must fail open.

```ts title="src/auth/last-used-login-method.ts"
const loginMethods = [
  "password",
  "email_otp",
  "magic_link",
  "passkey",
  "oauth:google",
  "oauth:github",
] as const;

export type LoginMethod = (typeof loginMethods)[number];

const allowed = new Set<string>(loginMethods);
const key = "app:last-used-login-method:v1";

export function readLastUsedLoginMethod(): LoginMethod | undefined {
  if (typeof window === "undefined") return undefined;
  try {
    const value = window.localStorage.getItem(key);
    return value !== null && allowed.has(value)
      ? (value as LoginMethod)
      : undefined;
  } catch {
    return undefined;
  }
}

export function rememberLastUsedLoginMethod(method: LoginMethod): void {
  if (typeof window === "undefined") return;
  try {
    window.localStorage.setItem(key, method);
  } catch {
    // Authentication remains successful when preference storage is unavailable.
  }
}

export function rememberAuthenticated<Result extends { readonly type: string }>(
  method: LoginMethod,
  result: Result
): Result {
  if (result.type === "authenticated") rememberLastUsedLoginMethod(method);
  return result;
}
```

Use the helper at the final client boundary. A rejected request or cancelled WebAuthn ceremony never reaches the write:

```ts title="src/auth/sign-in.ts"
import { createAuthClient } from "@effect-auth/core/Client";
import { rememberAuthenticated } from "./last-used-login-method";

export const auth = createAuthClient();

export async function signInWithPassword(email: string, password: string) {
  const result = await auth.password.signIn({
    identity: { scope: { type: "global" }, kind: "email", value: email },
    password,
  });
  return rememberAuthenticated("password", result);
}

export async function signInWithPasskey() {
  const result = await auth.passkey.signIn();
  return rememberAuthenticated("passkey", result);
}
```

Read the value after client mount and compare exact method IDs when rendering provider buttons. Do not clear it during logout if the next logged-out page should retain the badge; offer an explicit privacy/reset action that does clear it.

## Preserve the primary method [#preserve-the-primary-method]

MFA, email verification, passkey enrollment, and login approval are successful protocol continuations, but no login has completed. Carry the primary method in route state or `sessionStorage` keyed by `flowId`, then store it only when the continuation returns `authenticated`:

```ts title="src/auth/complete-mfa.ts"
const primaryMethod = "password" as const;
const login = await auth.password.signIn(input);

if (login.type === "authenticated") {
  rememberLastUsedLoginMethod(primaryMethod);
} else if (login.type === "requires_mfa") {
  const completed = await auth.mfa.totp.verify({ flowId: login.flowId, code });

  if (completed.type === "authenticated") {
    rememberLastUsedLoginMethod(primaryMethod);
  }
}
```

Apply the same rule to approval polling and other finalizers. When navigation interrupts the flow, store the allow-listed method in guarded `sessionStorage` keyed by `flowId`; expire it and remove it after success or terminal failure. It is a correlation hint, not proof of a valid `flowId`.

## Complete OAuth before recording [#complete-oauth-before-recording]

The OAuth callback is application-owned and knows the verified provider. Redirect to a dedicated success page only after trusted provider identity has passed through `AuthFlow` and the session cookie has been committed. That page can confirm `auth.session.currentOrUndefined()` and store `oauth:google`; denied consent, invalid state, account-linking requirements, and MFA continuations must not overwrite the previous hint.

For server-rendered Cloudflare pages, replace `localStorage` with a separate preference cookie such as `__Host-last-auth-method`, set alongside the session only on final success. Use `Secure; HttpOnly; SameSite=Lax; Path=/`, an explicit `Max-Age`, no `Domain`, and the same server-side allow-list. In a split Alchemy v2 deployment, return the original auth `Response` through the service binding so both `Set-Cookie` headers survive; no new Cloudflare resource is required.

| Requirement               | Storage choice                                                    |
| ------------------------- | ----------------------------------------------------------------- |
| Badge on this browser     | `localStorage` (recommended)                                      |
| Badge during Worker SSR   | Separate app preference cookie                                    |
| Preference across devices | App-owned user preference, available only after identity is known |

Do not query a pre-login email for its previous method: that can disclose whether an account exists or which authenticators it uses. Never use the hint for authorization, provider/account linking, account recovery, or authentication evidence. A shared browser may reveal that Google or another provider was used, so store neither email nor provider account ID.

## Verify the behavior [#verify-the-behavior]

1. Successful password, OTP, magic-link, passkey, and each OAuth provider replace the hint; starts, failures, cancellations, and malformed responses do not.
2. Continuations preserve the primary method and write only after final authentication; stale or missing pending state fails open.
3. SSR, disabled storage, malformed values, and providers removed from the allow-list render no badge without blocking login.
4. Logout preserves the hint, while the explicit privacy/reset action removes it.

Continue with [Browser Client](/clients/browser-client/), [OAuth Login](/recipes/add-oauth-login/), [Sessions](/concepts/sessions/), and [Testing](/guides/testing/).

