Voult SDKs: adding sign-in to your app

Which package to use

Starting a new app? Run npx create-voult-app. Adding Voult to an app you already have? Install one server package (@voult/express or @voult/next) plus @voult/react for the browser. Either way you write no sign-in, MFA, session or OAuth routes: the server package serves them all at /api/auth.

You have Install You write
Nothing yet npx create-voult-app my-app Nothing: pick a template, add your Client ID and Secret
An Express app @voult/express @voult/sdk, plus @voult/react for a React frontend 2 lines to mount the router, requireAuth on your own routes
A Next.js App Router app @voult/next @voult/sdk @voult/react 1 route file, a provider, requireSession() on protected pages
A React frontend on either @voult/react <VoultProvider>, then useSession() and useVoult()
Any project @voult/cli (dev dependency) npx voult init writes .env; npx voult doctor checks the setup

The other packages are building blocks the ones above use:

The browser only ever calls your own server; your server holds the client secret and talks to Voult.

Current versions: @voult/express 0.3.0, @voult/next 0.1.2, @voult/react 0.2.0, @voult/sdk 0.2.1, @voult/core 0.2.1, @voult/cli 0.2.4, create-voult-app 0.1.0. They need Node 20.12 or later and work with the Voult API 1.3.

Start a new app: create-voult-app

One command gives you a working app with sign-up, password, Google/GitHub, magic links, passkeys, MFA, sessions and linked accounts:

npx create-voult-app my-app
cd my-app
npm run dev
  1. Create an app in the Voult dashboard and copy its Client ID and Client Secret.
  2. Run the command. It asks for a template and your credentials, installs the packages and writes .env (with a generated session secret).
  3. Add the callback URL it prints under Callback URLs in the dashboard. That one URL also covers magic links.
  4. Turn on Google or GitHub under Sign-in providers to get those buttons.
  5. Run npm run doctor to check credentials, callback URL and providers.
Template What you get Local URL Callback URL to add
express-react (default) Express server + Vite/React UI, every feature http://localhost:5173 http://localhost:5173/api/auth/oauth/callback
nextjs Next.js App Router, every feature, server-side requireSession() http://localhost:3000 http://localhost:3000/api/auth/oauth/callback
minimal Express + one HTML page, no build step http://localhost:3000 http://localhost:3000/api/auth/oauth/callback

Useful flags:

npx create-voult-app my-app --template nextjs --client-id <id>   # secret: prompted, or VOULT_CLIENT_SECRET=…
npx create-voult-app my-app --no-install                         # copy the template only
pnpm create voult-app my-app                                      # pnpm, yarn and bun work too

The folder must be new or empty. The secret is never printed or passed on the command line, and each create-voult-app release pins the @voult/* versions it was tested with.

Add Voult to an existing Express app

Mount the router and every auth route exists under /api/auth; protect your own routes with requireAuth.

npm install @voult/express @voult/sdk
npm install -D @voult/cli && npx voult init     # writes .env
// server.js
import express from 'express';
import { createVoultMiddleware, createVoultRouter, requireAuth } from '@voult/express';

const app = express();
app.set('trust proxy', 1);                   // correct https URLs behind Render, NGINX, …
app.use(createVoultMiddleware());            // req.voult on every request
app.use('/api/auth', createVoultRouter());   // every auth route

// Your own API: 401 JSON unless someone is signed in
app.get('/api/orders', requireAuth, (req, res) => {
  const user = req.voult.getCurrentUser();   // { id, email, … }
  res.json({ orders: [], owner: user.email });
});

app.listen(3000);
node --env-file=.env server.js
// vite.config.js
export default { server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: false } } } };

The callback URL to add in the dashboard is wherever the browser reaches /api/auth/oauth/callback: http://localhost:5173/api/auth/oauth/callback with the Vite proxy above, http://localhost:3000/… without it. Hosted OAuth then needs nothing more: provider credentials stay in the dashboard, never in your .env.

Where OAuth and magic links send the browser afterwards can be changed: createVoultRouter({ oauth: { successPath: '/', mfaPath: '/mfa', errorPath: '/login' } }). The error page receives ?voult_error=<CODE>&voult_error_description=….

Add Voult to an existing Next.js app

Two files give a Next.js App Router app (14.2 or later) every auth route; requireSession() protects pages on the server.

npm install @voult/next @voult/sdk @voult/react
npm install -D @voult/cli && npx voult init     # writes .env (Next loads it)

1. The route file: every auth route

// app/api/auth/[...voult]/route.js
import { createVoultRouteHandlers } from '@voult/next';

export const { GET, POST, PATCH, DELETE } = createVoultRouteHandlers();

2. The provider: session state in the browser

// app/providers.jsx
'use client';
import { VoultProvider } from '@voult/react';
export default function Providers({ children }) {
  return <VoultProvider apiBase="/api/auth">{children}</VoultProvider>;
}

Wrap the body of app/layout.jsx in <Providers>. Client components can now use the @voult/react hooks below.

3. Protect pages and routes on the server

// app/account/page.jsx (Server Component)
import { requireSession } from '@voult/next';

export default async function Account() {
  const { user } = await requireSession();   // signed out → /login?returnTo=/account
  return <p>Signed in as {user.email}</p>;
}
// app/api/orders/route.js (your own API)
import { getSession } from '@voult/next';

export async function GET() {
  const session = await getSession();
  if (!session) return Response.json({ error: { code: 'AUTHENTICATION_ERROR', status: 401 } }, { status: 401 });
  return Response.json({ orders: [], owner: session.user.email });
}

4. Optional: redirect signed-out visitors before the page renders

// proxy.js (Next 16)  ·  middleware.js (Next 14–15)
import { voultMiddleware } from '@voult/next/middleware';

export default voultMiddleware({ publicPaths: ['/', '/login', '/blog/*'], signInPath: '/login' });
export const config = { matcher: ['/((?!_next/|favicon.ico|.*\\..*).*)'] };

Import it from @voult/next/middleware (the Edge-safe entry point), not @voult/next. It only checks that a correctly signed session cookie exists, so it's a convenience, not the security boundary: pages that must be protected still call requireSession(). It never redirects /api/*, so your API routes answer 401 JSON themselves.

One habit to keep: after signing in or out, navigate with a full page load (window.location.assign('/account')), not router.push(). Next prefetches links, and its router can replay a page it fetched while the visitor was signed out.

Callback URL to add: http://localhost:3000/api/auth/oauth/callback (and your production domain's equivalent).

The browser: @voult/react

@voult/react talks only to your own server's /api/auth routes: no secret and no token ever reaches the browser. Wrap the app once, then use two hooks.

// main.jsx (Vite) — in Next.js this is app/providers.jsx
import { VoultProvider } from '@voult/react';

createRoot(root).render(
  <VoultProvider apiBase="/api/auth">
    <App />
  </VoultProvider>
);

useSession(): who is signed in

const { status, user, isAuthenticated, refresh } = useSession();
// status: 'loading' | 'authenticated' | 'unauthenticated' | 'mfa_required'

useVoult(): sign-in actions

Each action updates useSession(); failures throw VoultRequestError with code, status and message.

function SignIn() {
  const { status } = useSession();
  const { signIn, signUp, verifyMfa, signOut } = useVoult();
  const [error, setError] = useState('');

  if (status === 'mfa_required') {
    return <form onSubmit={(e) => { e.preventDefault(); verifyMfa(e.target.code.value).then(() => location.assign('/account')); }}>
      <input name="code" autoComplete="one-time-code" /> <button>Verify</button>
    </form>;
  }

  return <form onSubmit={async (e) => {
    e.preventDefault();
    try {
      const { mfaRequired } = await signIn({ email: e.target.email.value, password: e.target.password.value });
      if (!mfaRequired) location.assign('/account');          // otherwise status becomes 'mfa_required'
    } catch (err) { setError(err.message); }                  // e.g. "Invalid email or password"
  }}>…</form>;
}

Google, GitHub and other providers: OAuthButton and useOAuthProviders()

const { providers } = useOAuthProviders();   // only providers enabled AND configured in the dashboard
return providers.map((p) => (
  <OAuthButton key={p} provider={p} returnTo="/account">Continue with {p}</OAuthButton>
));

OAuthButton is a plain link, so it works without JavaScript too. After the round trip, getOAuthRedirectResult() reads ?voult_error= or ?voult_linked= from the URL so you can show a message.

Validation helpers that match the API's rules, with no network code: isValidEmail, isValidPassword, isValidUsername and PASSWORD_REQUIREMENTS_MESSAGE (8+ characters with upper, lower, digit and one of @$!%*?&).

Feature recipes

Every account feature is one call on useVoult() (@voult/react 0.2+, with @voult/express 0.3+ or @voult/next on the server). Each returns the router's JSON and throws VoultRequestError on failure.

const { mfa, passkeys, magicLink, sessions, linkedAccounts, deleteAccount } = useVoult();

Two-factor authentication (TOTP)

const { secret, qrCode, backupCodes } = await mfa.setup();   // show qrCode (an image URL) + the backup codes
await mfa.enable(code);                                      // the 6-digit code from their app turns it on

await mfa.status();                                          // { mfaEnabled, backupCodesRemaining }
await mfa.regenerateBackupCodes(code);                       // { backupCodes } — the old ones stop working
await mfa.disable({ code, password });                       // password can be omitted for Google/GitHub accounts

Sign-in with MFA needs no extra code: after signIn() (or OAuth, or a magic link) the session status is mfa_required; call verifyMfa(code) with a 6-digit or backup code. mfa.cancel() abandons that pending sign-in. Hosted OAuth and magic links send MFA users to /mfa (change with oauth.mfaPath).

Passkeys

The browser runs the WebAuthn ceremony; @simplewebauthn/browser (npm i @simplewebauthn/browser) does it in one call.

import { startAuthentication, startRegistration } from '@simplewebauthn/browser';

// Add a passkey (signed in)
const { options } = await passkeys.registrationOptions('MacBook');
await passkeys.register(await startRegistration({ optionsJSON: options }), 'MacBook');

// Sign in with one (signed out)
const { options: login } = await passkeys.loginOptions();
const { mfaRequired } = await passkeys.signIn(await startAuthentication({ optionsJSON: login }));

await passkeys.list();                  // { credentials: [{ id, deviceName, … }] }
await passkeys.rename(id, 'Work laptop');
await passkeys.remove(id);
await magicLink.send(email, { returnTo: '/account' });   // always { sent: true }, account or not

Voult emails a link to /api/auth/magic-link/verify on your server, which signs the user in (or sends them to /mfa) and redirects to returnTo. It sits on the same origin as your OAuth callback, so the one callback URL you allowlisted covers it.

Sessions ("where you're signed in")

const { sessions: list } = await sessions.list();   // [{ id, userAgent, ipAddress, createdAt, isCurrent }]
const { current } = await sessions.revoke(id);      // revoking your own (current: true) signs this browser out

Linked accounts

const { providers } = await linkedAccounts.list();
await linkedAccounts.unlink('github');
<OAuthButton provider="github" intent="link" returnTo="/account">Link GitHub</OAuthButton>   // signed in

Account deletion

await deleteAccount();   // Voult disables the account (it can be re-enabled); this browser is signed out

Password reset and email verification

These pages are opened from emails, so call the router directly:

await fetch('/api/auth/user/forgot-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) });

// /reset-password?token=…&appId=…
await fetch('/api/auth/user/reset-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, appId, newPassword }) });

// /verify-email?token=…&appId=…
await fetch(`/api/auth/user/verify-email?token=${token}&appId=${appId}`);

The templates from create-voult-app include every one of these screens, ready to copy.

Configuration and the CLI

The server packages read their settings from .env; npx voult init writes it and npx voult doctor checks it.

Variable Required What it is
VOULT_CLIENT_ID Yes Your app's Client ID from the dashboard
VOULT_CLIENT_SECRET Yes Its Client Secret. Server only, never in the browser
VOULT_SESSION_SECRET In production 32+ random characters that sign the session cookies (openssl rand -hex 32); voult init generates one
VOULT_APP_URL When the frontend is on another origin Where OAuth and magic links send the browser back to, e.g. https://myapp.com
VOULT_OAUTH_CALLBACK_URL Rarely Overrides the computed callback URL when a proxy hides the public URL
VOULT_SESSION_STRATEGY No cookie (default, httpOnly cookies) or bearer (tokens returned in JSON, for mobile apps)
VOULT_BASE_URL No Only to point at a Voult other than the default

voult init asks for the Client ID and Secret, then writes .env (mode 600) and .env.example. For scripts and CI, skip the prompts:

VOULT_CLIENT_SECRET=… npx voult init --yes --client-id <id>        # the secret stays out of shell history
npx voult init --yes --client-id <id> --client-secret <secret> --strategy cookie --quiet
npx voult init --force                                                # overwrite an existing .env

voult doctor checks the packages, .env, the session secret, that the API is reachable, your credentials, the callback URL allowlist, https in production and which providers are ready. Every problem comes with the fix, and it never prints a secret.

npx voult doctor
npx voult doctor --callback-url https://myapp.com/api/auth/oauth/callback
npx voult doctor --json        # exit code 1 on any failure, for CI

Callback URLs live in the dashboard under your app. Add the exact URL the browser uses for /api/auth/oauth/callback: scheme, host, port and path must match. Magic links may point anywhere on the same origin as an allowlisted callback, so the one URL covers both.

Errors, tokens, versions and going live

Errors. Every failed /api/auth call answers JSON, and @voult/react throws it as VoultRequestError:

{ "error": { "code": "AUTHENTICATION_ERROR", "message": "Invalid email or password", "status": 401 } }

Show message to the user and branch on status (401 signed out, 403 not allowed, 409 already exists, 423 locked, 429 slow down). The router currently reports the SDK's error category in code (AUTHENTICATION_ERROR, VALIDATION_ERROR, …), while the Voult API's own codes such as INVALID_CREDENTIALS are listed with their fixes in the error catalog. Hosted OAuth and magic-link errors arrive on your error page as ?voult_error=<CODE>.

Token lifetimes. You never handle tokens, but it helps to know the clock:

Token Lives Renewed
Access token 15 minutes Automatically from the refresh token, by requireAuth, getSession() and every router call
Refresh token 30 days Rotated on every renewal; parallel requests share the new one for 30 seconds
MFA sign-in step 5 minutes Start sign-in again
Magic link, OAuth round trip 10 minutes Send a new link or click the button again

Versions. Packages are 0.x until launch: a minor (0.3 → 0.4) may contain breaking changes, each marked Breaking in that package's CHANGELOG; a patch never does. npm install writes ^0.3.0, which never crosses a minor, so you upgrade on purpose. Anything deprecated keeps working for at least 2 minor releases and 90 days. The full policy and compatibility table: Versioning.

Going to production:

Upgrading

Nothing here breaks existing code except where marked; upgrade @voult/sdk to 0.2.1 first if you build Next.js apps.

Released Package What changed What to do
Upcoming create-voult-app 0.1.0 New: scaffold an Express + React, Next.js or minimal app npx create-voult-app my-app
Upcoming @voult/cli 0.2.4 voult init --yes and credential flags for scripts and CI Nothing; optional flags
Upcoming @voult/next 0.1.2 The middleware no longer redirects /api/*, so fetch() gets your route's 401 JSON instead of the sign-in page Upgrade
2026-10-14 @voult/sdk 0.2.1 Fix: SDK calls failed in minified production builds ("Voult client instance is required"), which broke @voult/next on Vercel Upgrade if you use Next.js
2026-10-14 @voult/next 0.1.1 Fix: after a server action renewed the session, the same-request render saw the old cookie Upgrade; needs @voult/sdk 0.2.1
2026-10-11 Voult API 1.3 Parallel requests after the access token expires no longer sign users out; a revoked session coming back no longer signs out your other sessions Nothing (server-side)
2026-10-11 @voult/next 0.1.0 New: Next.js App Router support See Add Voult to an existing Next.js app
2026-10-10 @voult/express 0.3.0, @voult/react 0.2.0 Every account feature is a router route and a useVoult() helper (MFA, passkeys, magic links, sessions, linked accounts, deletion) Delete any of these routes you wrote yourself; switch pages to the helpers
2026-10-10 Voult API 1.2 Security: magic links only go to your callback URL's origin, and respect MFA Allowlist the callback URL if your magic-link page is elsewhere
2026-10-03 @voult/express 0.3.0-beta.0 The router runs on @voult/core; express.json() no longer needed; invalid JSON answers 400 INVALID_JSON Nothing; public API unchanged
2026-10-03 Voult API 1.1 Access tokens live 15 minutes everywhere (was 7 days outside production); X-Voult-API-Version header Nothing with the packages; raw-SDK bearer users call refreshSession on 401

Each package's CHANGELOG has the full notes: voult-sdk on GitHub.