React authentication: add sign-in with MFA to a React app
A client, a session hook, a sign-in form that handles a second factor, and a protected route, in about a hundred lines.

On this page
This guide adds authentication to an existing React app with
@nulvless/nulvless-js. By the end you will have a session that survives
reloads, a sign-in form that copes with multi-factor authentication, and a
route that only renders for signed-in users.
Everything here runs in the browser against the publishable key. For permission checks a user must not be able to skip, pair it with your own API, as described in the Node guide.
Build this with your agent
A ready prompt for Claude Code, Cursor, ChatGPT or any coding agent. It points the agent at this guide and the docs, so it follows the real API instead of guessing.
Before you start
You need a project in the console and the issuer
URL and publishable key of the environment you are building against. Use
develop while you work: it is a separate store, so the test users you create
here never reach production.
npm install @nulvless/nulvless-jsCreate one client
Put the client in its own module and import it everywhere. The publishable key is safe in the bundle: it can only do what the signed-in user could already do.
import { createClient, Storage } from '@nulvless/nulvless-js';
export const nulvless = createClient(
import.meta.env.VITE_NULVLESS_URL,
import.meta.env.VITE_NULVLESS_KEY,
{ storage: Storage.Local, trackUser: true },
);Storage.Local keeps people signed in between visits. trackUser lets the
platform record the device and location of each session, which is what the
user later sees on their list of active sessions.
Track the session with one hook
The SDK emits events whenever authentication changes anywhere in the app, so a single hook can keep React in sync.
import { useEffect, useState } from 'react';
import type { Session } from '@nulvless/nulvless-js';
import { nulvless } from './nulvless';
export function useSession() {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
nulvless.auth.getSession().then(({ data }) => {
setSession(data);
setLoading(false);
});
const offIn = nulvless.auth.on('signed-in', () => {
nulvless.auth.getSession().then(({ data }) => setSession(data));
});
const offOut = nulvless.auth.on('signed-out', () => setSession(null));
const offDead = nulvless.auth.on('unauthorized', () => setSession(null));
return () => {
offIn();
offOut();
offDead();
};
}, []);
return { session, loading };
}on returns its own unsubscribe function, so returning them from the effect is
all the cleanup you need.
Sign in, and expect a challenge
A sign-in can end in two ways: a session, or a challenge when the user has a second factor enrolled. The form has to handle both.
import { useState } from 'react';
import type { Challenge } from '@nulvless/nulvless-js';
import { nulvless } from './lib/nulvless';
import { VerifyCode } from './VerifyCode';
export function SignIn() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [challenge, setChallenge] = useState<Challenge | null>(null);
const [error, setError] = useState<string | null>(null);
async function submit(event: React.FormEvent) {
event.preventDefault();
setError(null);
const { data, error } = await nulvless.auth.signIn({ email, password });
if (error) return setError('Check your email and password.');
if (data.challenge) return setChallenge(data.challenge);
}
if (challenge) return <VerifyCode challenge={challenge} />;
return (
<form onSubmit={submit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" />
<input value={password} onChange={(e) => setPassword(e.target.value)} type="password" />
{error && <p role="alert">{error}</p>}
<button type="submit">Sign in</button>
</form>
);
}When there is no challenge, there is nothing left to do: the session is stored
and the signed-in event has already reached your hook.
One component for every code
The same component can collect the code for MFA, a one-time sign-in code or a sudo prompt, because the challenge itself says which flow it belongs to.
import { useState } from 'react';
import type { Challenge } from '@nulvless/nulvless-js';
import { nulvless } from './lib/nulvless';
export function VerifyCode({ challenge }: { challenge: Challenge }) {
const [code, setCode] = useState('');
const [error, setError] = useState<string | null>(null);
async function submit(event: React.FormEvent) {
event.preventDefault();
const { error } = await nulvless.auth.verifyChallenge({
token: challenge.token,
code,
});
if (error) setError('That code did not work.');
}
return (
<form onSubmit={submit}>
<p>We sent a code to {challenge.identifier}</p>
<input value={code} onChange={(e) => setCode(e.target.value)} inputMode="numeric" />
{error && <p role="alert">{error}</p>}
<button type="submit">Confirm</button>
</form>
);
}challenge.identifier arrives already masked, like u***@example.com, so it is
safe to show on screen.
Protect a route
import { useSession } from './lib/use-session';
import { SignIn } from './SignIn';
export function Protected({ children }: { children: React.ReactNode }) {
const { session, loading } = useSession();
if (loading) return null;
if (!session) return <SignIn />;
return children;
}Wrap any part of the app in <Protected>. When the session ends, whether the
user signed out, it expired or it was revoked from another device, the hook
receives the event and the route falls back to the sign-in form.
Sign out
<button onClick={() => nulvless.auth.signOut()}>Sign out</button>Pass 'others' to end every other session and keep this one, or 'global' to
end all of them.
Where to go next
- Add social sign-in by rendering the providers from
getMethods(), so turning one on in the console needs no redeploy. The React guide has the callback route. - Protect the dangerous actions in your settings screen with sudo mode.
- Read Why staging users can never reach production
before you promote your configuration to
main.
