Show the Last Used Login Method
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.
| 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
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.
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:
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
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:
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
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
- Successful password, OTP, magic-link, passkey, and each OAuth provider replace the hint; starts, failures, cancellations, and malformed responses do not.
- Continuations preserve the primary method and write only after final authentication; stale or missing pending state fails open.
- SSR, disabled storage, malformed values, and providers removed from the allow-list render no badge without blocking login.
- Logout preserves the hint, while the explicit privacy/reset action removes it.
Continue with Browser Client, OAuth Login, Sessions, and Testing.