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.

Gleisson MatosFounder & CEO2 min read
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.

Add sign-in with multi-factor authentication to my React app using Nulvless.

Follow these pages. They document the real API, so do not guess method names:
- https://www.nulvless.com/llms.mdx/blog/react-sign-in-with-mfa/content.md
- https://www.nulvless.com/llms.mdx/docs/frameworks/react/content.md
- https://www.nulvless.com/llms.mdx/docs/sdk/auth/content.md

Requirements:
- Install @nulvless/nulvless-js and create one client in src/lib/nulvless.ts. Read the issuer URL and the publishable key from VITE_NULVLESS_URL and VITE_NULVLESS_KEY.
- Track the session with a useSession hook that listens to the signed-in, signed-out and unauthorized events.
- The sign-in form must handle data.challenge and show a code step that calls auth.verifyChallenge.
- Wrap private routes in a Protected component.
- Only the publishable key belongs in the browser. Never put a secret key in frontend code.

Before you start, ask me for the issuer URL and publishable key of my develop environment.
or open in

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-js

Create 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.

src/lib/nulvless.ts
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.

src/lib/use-session.ts
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.

src/SignIn.tsx
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.

src/VerifyCode.tsx
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

src/Protected.tsx
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.

Keep reading