React Router Framework Mode can start a hosted passkey login from a server action and receive the result in a server loader. Sudomimus runs the WebAuthn challenge. The official @sudomimus/react-router adapter handles the Connect exchange and server session; your application decides what the signed-in user may do.
Start with a Node.js Framework Mode project. Install @sudomimus/react-router, @sudomimus/web, @sudomimus/connect, and @sudomimus/session. This guide uses Connect; see OIDC or Connect if you need to choose a protocol. The application sign-in page shows the hosted experience.
The example uses a single process and in-memory storage for development. Production requires a shared durable store. A browser-only SPA cannot run these server handlers.
1. Configure passkeys and the Connect callback
In the With portal, create an organization and application. Save its immutable applicationAnchor. Register the application’s RS256 client-auth public key and keep the paired private key in your backend’s secret store. This key signs Connect requests; it is separate from a user’s passkey.
Configure all three rule layers:
| Rule layer | Configuration |
|---|---|
| Authentication | Allow PASSKEY_USERNAMELESS for a passkey button before the email field, PASSKEY_REASONED for email-first login, or both. |
| Account admission | Add a rule that admits your test account. A successful passkey challenge alone does not grant entry. |
| Return | Add a CALLBACK rule that permits the host of your callback URL. |
Use https://app.example.com/auth/callback in production, replacing the host with yours. For local development, Connect also permits a loopback HTTP callback such as http://localhost:5173/auth/callback. Permit its host in the application’s CALLBACK rule. Keep the callback on the same origin as the page that submits login and logout. Do not add exposure-key or confirmation-key to the configured URL; Connect appends those query parameters.
Activate the application after configuring its rules. Register a passkey in your Sudomimus account before testing. Enabling a passkey rule does not enroll a credential for the user. See the passkey rule guide and Connect setup.
2. Configure the server module
Set the application anchor, callback URL, and private-key file path in the server environment. For example, a local callback can use http://localhost:5173/auth/callback. Keep the private PEM outside your public assets and source repository. Do not expose it through browser environment variables.
Create one server-only module. The explicit production check prevents deployment with local in-memory storage.
import { readFileSync } from "node:fs";import { ConnectClient } from "@sudomimus/connect";import { createReactRouterHandlers } from "@sudomimus/react-router";import { SessionClient } from "@sudomimus/session";import { MemoryWebAuthStore } from "@sudomimus/web";
function requiredEnvironment(name: string): string { const value = process.env[name]; if (!value) { throw new Error(`Missing server environment variable: ${name}`); } return value;}
if (process.env.NODE_ENV === "production") { throw new Error("Configure a shared WebAuthStore before production");}
const applicationAnchor = requiredEnvironment("SUDOMIMUS_APPLICATION_ANCHOR");const connect = new ConnectClient({ clientAuth: { applicationAnchor, privateKeyPem: readFileSync( requiredEnvironment("SUDOMIMUS_CLIENT_AUTH_PRIVATE_KEY_FILE"), "utf8", ), },});
export const sudomimus = createReactRouterHandlers({ applicationAnchor, callbackUrl: requiredEnvironment("SUDOMIMUS_CALLBACK_URL"), afterLoginUrl: "/dashboard", afterLogoutUrl: "/", connect, session: new SessionClient(), store: new MemoryWebAuthStore(), pendingTtlSeconds: 600, sessionTtlSeconds: 86_400,});The adapter signs /establish, stores the pending Inquiry keys, redirects to the hosted login, redeems the callback once, and verifies the returned access token. hiddenKey, access tokens, and refresh tokens stay in the server store. The browser receives an opaque session cookie.
current() refreshes access tokens near expiry and persists the rotated refresh token. Login creates a fresh local session identifier. Logout revokes the Session API session and clears cookies. The configured local session lasts at most one day; restarting this development server loses pending logins and sessions. Production HTTPS callbacks enable Secure cookies; HttpOnly and SameSite=Lax apply to both environments.
3. Register actions, the callback loader, and page routes
Declare the routes in app/routes.ts. React Router’s route configuration maps each path to a route module.
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [ index("routes/home.tsx"), route("login", "routes/login.tsx"), route("auth/callback", "routes/auth.callback.tsx"), route("logout", "routes/logout.tsx"), route("dashboard", "routes/dashboard.tsx"),] satisfies RouteConfig;Use actions for login start and logout. Use a loader for the GET callback. Return the handler responses directly so their redirects and Set-Cookie headers reach the browser.
import { sudomimus } from "../sudomimus.server";
export const action = sudomimus.startAction;import { sudomimus } from "../sudomimus.server";
export const loader = sudomimus.callbackLoader;import { sudomimus } from "../sudomimus.server";
export const action = sudomimus.logoutAction;The home page submits a POST. A GET link to /login does not start this flow.
import { Form } from "react-router";
export default function Home() { return ( <Form method="post" action="/login"> <button type="submit">Sign in with Sudomimus</button> </Form> );}4. Return only the fields the page needs
Read the current session in the dashboard’s server loader. Return only its subject; keep the token pair and pending-login state on the server.
import { Form, type LoaderFunctionArgs, redirect, useLoaderData } from "react-router";import { sudomimus } from "../sudomimus.server";
export async function loader({ request }: LoaderFunctionArgs) { const session = await sudomimus.current({ request }); if (!session) { throw redirect("/"); } return { subject: session.subject };}
export default function Dashboard() { const { subject } = useLoaderData<typeof loader>(); return ( <main> <h1>Signed in</h1> <p>User: {subject}</p> <Form method="post" action="/logout"> <button type="submit">Sign out</button> </Form> </main> );}subject is an application-visible sector-pairwise identifier. Map it to your own user record instead of treating it as an email address. Your application still enforces its own permissions for protected data and operations. The subject comes from a verified token; offline verification does not observe later platform revocation immediately.
5. Test login, refresh, and logout
Open the application, sign in with a registered passkey, and confirm that the browser reaches /dashboard. Sign out, then revisit /dashboard: the loader should redirect to /. To check refresh, remain signed in until the access token approaches expiry and load the dashboard again. The server should rotate the token pair without returning either token to the page.
| Symptom | Check |
|---|---|
| No passkey choice appears | The application admits the intended passkey method, and your account has a registered passkey. |
| Login is denied after the challenge | The application is active and its account-admission rules admit the account. |
| Establish fails | The application anchor and registered RS256 key match the private key used by the server. |
| The callback is rejected | Its host is permitted, and the browser retains the pending cookie. Start a new login after a restart or a consumed callback. |
| Start or logout reports an invalid origin | The browser page origin matches the configured callback origin. Keep the POST origin check enabled. |
| Refresh fails after switching instances | All instances use the same store and serialize refresh for each local session. |
Before production, replace MemoryWebAuthStore with a shared durable WebAuthStore, then remove the local-only guard. takePending() must consume a matching transaction atomically. withSession() must lock across reading, refreshing, and persisting a session, so concurrent requests do not reuse the same refresh token. Match storage expiry to the configured TTL and protect stored tokens and keys.
Deploy behind HTTPS. Keep the adapter’s POST origin checks, cookie protections, callback checks, and token verification. Avoid logging callback query parameters, cookies, private keys, or tokens. When an operation needs current platform session authority, use live Session introspection; offline token verification cannot observe revocation immediately.
See the React Router integration reference and Session lifecycle guide for the storage and verification contracts. Connect profile data uses Session UserInfo; an OIDC application uses a separate endpoint and scopes, as explained in the email troubleshooting guide.