Add NamoID Auth to a React Application
A React application should not implement password verification, OTP delivery, passkey ceremonies, MFA, or social-provider callbacks in client components. Redirect the browser to a hosted authentication surface, receive a one-time callback code, exchange it with PKCE, and keep the resulting short-lived access token out of persistent browser storage.
This tutorial builds that flow with @namoidhq/react 1.0.0 in a Vite React application. It uses NamoID's native Hosted Auth path, so you need an environment auth publishable key and callback configuration, not an OAuth Application.
The result is intentionally browser-only: the access token lives in React memory and no refresh token is issued. For a durable web session, put the callback exchange and session cookie on a backend-for-frontend instead.
What you will build
React sign-in button
-> NamoIDProvider reads environment config
-> SDK creates state + PKCE transaction
-> browser redirects to NamoID Auth
-> user completes sign-in and policy
-> callback receives one-time code + state
-> SDK validates state and exchanges with PKCE
-> React holds short access token in memoryYou will test sign-in, refresh behavior, callback replay, and configuration failures before treating the integration as complete.
Prerequisites
Create or select a NamoID project and its Test environment. In that environment:
- Enable at least one sign-in method under NamoID Auth.
- Create an Auth publishable key.
- Add
http://localhost:5173to the allowed web origins. - Add
http://localhost:5173/auth/callbackto the allowed callback URLs.
The publishable key is expected in frontend code. It identifies the environment and is protected by origin checks and rate limits; it does not grant server administration privileges. Never substitute an auth secret key in browser code.
Create the React project
Start with Vite's TypeScript template:
npm create vite@latest namoid-react-demo -- --template react-ts
cd namoid-react-demo
npm install
npm install @namoidhq/react
npm run devThe SDK supports React 18 and later. This tutorial targets @namoidhq/react 1.0.0; check release notes before copying code into a later major version.
Add the publishable key
Create .env.local:
VITE_NAMOID_PUBLISHABLE_KEY=namoid_auth_pk_test_...Vite includes VITE_* values in the browser bundle. That is correct for a publishable key and wrong for an auth secret. Keep server-only credentials in a backend environment without a public prefix.
Add a type declaration:
// src/vite-env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_NAMOID_PUBLISHABLE_KEY: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}Restart Vite after changing environment files.
Wrap the app with NamoIDProvider
The provider creates one NamoID client and makes it available to components and hooks below it.
// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { NamoIDProvider } from "@namoidhq/react";
import App from "./App";
const publishableKey = import.meta.env.VITE_NAMOID_PUBLISHABLE_KEY;
if (!publishableKey) {
throw new Error("VITE_NAMOID_PUBLISHABLE_KEY is required");
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<NamoIDProvider publishableKey={publishableKey}>
<App />
</NamoIDProvider>
</StrictMode>,
);Production defaults point to NamoID's hosted API. Local contributors can pass apiBaseUrl and hostedAuthBaseUrl, but customer applications normally omit both.
Add the sign-in screen
SignIn fetches browser-safe environment configuration and disables itself if no sign-in method is available. Clicking it creates a state and PKCE transaction in sessionStorage, then redirects to Hosted Auth.
// src/SignInPage.tsx
import { SignIn, useAuthConfig } from "@namoidhq/react";
const callbackUrl = `${window.location.origin}/auth/callback`;
export function SignInPage() {
const { config, loading, error, reload } = useAuthConfig();
return (
<main>
<h1>Sign in</h1>
{error ? (
<div role="alert">
Authentication configuration could not be loaded.
<button type="button" onClick={() => void reload()}>Try again</button>
</div>
) : null}
<SignIn
returnTo={callbackUrl}
title="Welcome back"
description="Continue to your workspace."
loadingLabel="Loading sign-in..."
/>
{!loading && config ? (
<small>
{config.access_mode} access · {config.signin_methods.length} enabled method(s)
</small>
) : null}
</main>
);
}Use HostedAuthButton when you want only a button and own the surrounding layout. SignUp, Waitlist, and AuthCard use the same hosted transaction underneath.
Do not render sign-up and waitlist as simultaneous ways around one access policy. In waitlist mode, direct new people through Waitlist; approved users can later enter sign-in or signup according to the environment policy.
Complete the callback
completeHostedAuthRedirect reads code and state, loads the saved transaction, compares state, removes the transaction, and exchanges the code with the original PKCE verifier.
// src/CallbackPage.tsx
import { useEffect, useState } from "react";
import { completeHostedAuthRedirect, useNamoID } from "@namoidhq/react";
export type HostedSession = Awaited<ReturnType<typeof completeHostedAuthRedirect>>;
type Props = {
onAuthenticated: (tokens: HostedSession) => void;
onNavigate: (path: string) => void;
};
export function CallbackPage({ onAuthenticated, onNavigate }: Props) {
const namoid = useNamoID();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
completeHostedAuthRedirect(namoid)
.then((tokens) => {
if (!active) return;
onAuthenticated(tokens);
window.history.replaceState({}, "", "/dashboard");
onNavigate("/dashboard");
})
.catch(() => {
if (active) setError("This sign-in could not be completed. Please start again.");
});
return () => {
active = false;
};
}, [namoid, onAuthenticated, onNavigate]);
if (error) {
return (
<main>
<h1>Sign-in interrupted</h1>
<p role="alert">{error}</p>
<a href="/">Return to sign in</a>
</main>
);
}
return <main aria-busy="true">Completing sign-in...</main>;
}Do not show raw SDK or API errors to end users. Send a sanitized error code and correlation ID to observability instead.
React Strict Mode can run development effects more than once. The active guard prevents state updates after unmount, while the one-time transaction prevents successful exchange twice. A second attempt should fail safely.
Hold the browser session in memory
For this browser-only example, keep the token response in component state. A page reload intentionally loses it and starts Hosted Auth again.
// src/App.tsx
import { useCallback, useState } from "react";
import { CallbackPage, type HostedSession } from "./CallbackPage";
import { SignInPage } from "./SignInPage";
export default function App() {
const [path, setPath] = useState(window.location.pathname);
const [session, setSession] = useState<HostedSession | null>(null);
const navigate = useCallback((nextPath: string) => setPath(nextPath), []);
if (path === "/auth/callback") {
return <CallbackPage onAuthenticated={setSession} onNavigate={navigate} />;
}
if (!session) return <SignInPage />;
return (
<main>
<h1>Dashboard</h1>
<p>Authentication complete.</p>
<button type="button" onClick={() => setSession(null)}>
Sign out of this app
</button>
</main>
);
}This is deliberately not a full router. Use the callback helper from the route component supplied by your existing router.
Your production host must also rewrite /auth/callback to the SPA entry document. Without that fallback, a browser refresh on the callback route will return the host's 404 page before React can complete the transaction.
The public token response's refresh_token is null. Do not work around that by copying the access token into localStorage, IndexedDB, or a long-lived JavaScript-readable cookie.
Call a protected API
Attach the access token only to the intended API origin:
export async function loadProfile(accessToken: string) {
const response = await fetch("https://api.example.com/profile", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (response.status === 401) throw new Error("session_expired");
if (!response.ok) throw new Error("profile_request_failed");
return response.json() as Promise<{ displayName: string }>;
}Your API must validate signature, issuer, audience, expiry, and authorization claims. React deciding that a user is signed in is not a server authorization control.
Never send tokens to analytics, error context, query strings, or another API audience. Redact Authorization headers at proxies and application loggers.
Use a backend-for-frontend for durable sessions
Most production web applications should exchange the callback code on a backend and set an application session cookie. That provides:
- server-only storage for an auth secret and rotating refresh token;
- an
HttpOnly,Securecookie unavailable to XSS; - central idle and absolute session expiry;
- server-side logout and revocation;
- no bearer token exposed to React components.
The React button can still start Hosted Auth. Point returnTo at the BFF callback, perform confidential exchange there, create the session, and redirect to the SPA without tokens in the URL.
Use the browser-only model for short-lived demos and public-client architectures with a correctly validating API. Use a BFF when you need persistence, refresh, sensitive data, or conventional web-session security.
Add signup or waitlist
The SDK exposes parallel components:
import { SignUp, Waitlist } from "@namoidhq/react";
<SignUp returnTo={`${window.location.origin}/auth/callback`} />
<Waitlist returnTo={`${window.location.origin}/auth/callback`} />SignUp checks access mode. Waitlist redirects to NamoID's verified waitlist by default, where ownership verification and abuse controls run before a pending request is created.
The optional onSubmitEmail prop supplies custom submission logic. If you use it, you own verification, anti-spam, privacy notice, rate limits, and state management. Hosted waitlist is the safer default.
Handle real failure paths
| Failure | Expected React behavior |
|---|---|
| Missing or invalid publishable key | Config error with retry or setup guidance |
| Origin not allowlisted | Safe integration error; no token response |
| Callback not allowlisted | Hosted page explains configuration problem |
| Callback state mismatch | Reject exchange and restart sign-in |
| Callback opened in another tab context | Missing transaction; restart sign-in |
| Expired or reused one-time code | Generic interrupted message |
| Access token expired | Clear memory and start Hosted Auth again |
| No methods enabled | SignIn remains unavailable |
Do not automatically retry a state mismatch or reused callback. Those are transaction-integrity failures, not transient network errors.
Verify the integration
Run:
npm run typecheck --if-present
npm run buildThen test:
- Sign in with a Test-environment user.
- Confirm the callback code disappears when history is replaced.
- Confirm tokens are absent from localStorage and sessionStorage.
- Refresh and confirm the in-memory session disappears.
- Sign in again and observe hosted-session reuse under current policy.
- Reopen the old callback URL and confirm safe failure.
- Try an unlisted origin and callback.
- Disable all methods and confirm the component is unavailable.
Use the NamoID console's Users, Sessions, and Audit views to confirm successful events appear in the Test environment.
Frequently asked questions
Does the SDK render credentials inside my app?
No. Components read browser-safe config and start Hosted Auth. Passwords, OTPs, passkeys, MFA, providers, and waitlist verification remain hosted.
Why use sessionStorage for the transaction?
It holds short-lived state and PKCE verifier data across the redirect and limits it to the tab session. It does not store the resulting access token.
Can I use it without React Router?
Yes. This example does. In production, invoke the callback helper from your existing router.
React or Next.js SDK?
Use @namoidhq/react for browser components and public completion. Use server-side Next.js helpers for a Next.js BFF, server callback, session cookie, and secret-key exchange.
The smallest secure React integration is a provider, one hosted sign-in component, one callback handler, and a deliberate token-storage decision. The SDK removes transaction plumbing; your app still decides session duration, API trust, and whether browser-only or BFF architecture fits the risk.