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:
@voult/core: the router itself, written once on the Fetch API (Request → Response).@voult/expressand@voult/nextare thin adapters over it. Install it only to write an adapter for another framework.@voult/sdk: the low-level client for the Voult API. Your server packages use it for you; you need it directly only for calls the router doesn't cover, or outside a web server.
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
- Create an app in the Voult dashboard and copy its Client ID and Client Secret.
- Run the command. It asks for a template and your credentials, installs the packages and writes
.env(with a generated session secret). - Add the callback URL it prints under Callback URLs in the dashboard. That one URL also covers magic links.
- Turn on Google or GitHub under Sign-in providers to get those buttons.
- Run
npm run doctorto 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
- No
express.json()needed for the router (since 0.3.0): it reads JSON bodies itself, up to 100 kB. If your app already parses bodies, that still works. requireAuthrenews an expired access token from the refresh cookie before deciding, so users aren't signed out after the 15-minute token lifetime.req.voultis the signed-in user's@voult/sdkclient. Any SDK function works on it in your own routes:getCurrentUser(req.voult),updateProfile({ fullName }, req.voult), and so on.- Sessions live in httpOnly cookies (
voult_access,voult_refresh,voult_user), signed withVOULT_SESSION_SECRET. No token ever reaches browser JavaScript. - Frontend on another port in development (Vite): proxy
/apito the server withchangeOrigin: false, so cookies stay on one origin and the OAuth callback URL is the frontend's:
// 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 });
}
getSession()returns{ user, verified }ornull, and works in Server Components, route handlers and server actions. Voult confirms the session on every call, so a revoked session is caught.verified: falseonly happens in a Server Component after the 15-minute access token expires: the user comes from the signed cookie, because a Server Component can't save a renewed token.requireSession()handles it for you by passing through/api/auth/session/renewand straight back. In route handlers and server actions,getSession()renews and saves the new cookies itself.requireSession({ signInPath: '/signin' })changes where signed-out visitors go.
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>;
}
signUp({ email, password, fullName }): creates the account and signs in.signIn({ username, password })works for username accounts too.signOut(): revokes the session at Voult and clears the cookies.
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);
Magic links
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:
- Set
VOULT_CLIENT_ID,VOULT_CLIENT_SECRET,VOULT_SESSION_SECRET(32+ characters) andNODE_ENV=productionon your host;VOULT_APP_URLif the frontend is on another origin. - Add your real
https://…/api/auth/oauth/callbackunder Callback URLs. - Behind a proxy (Render, NGINX, Heroku), keep
app.set('trust proxy', 1)in Express; setVOULT_OAUTH_CALLBACK_URLif the computed URL is still wrong. - Serve over https: cookies are
Securein production. - Keep
.envout of git (the templates'.gitignorealready does). - Run
npx voult doctoragainst the production values, andnpx voult doctor --jsonin CI.
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.