Add Voult to a new app — from zero to "Continue with Google"

This guide takes a brand-new codebase to working sign-up, sign-in, sessions, MFA and Google/GitHub sign-in. You write about 15 lines of server code and a few React components. You don't write any token handling, OAuth code, or password hashing, and you don't put any Google/GitHub keys in your .env.

Time: about 10 minutes for password auth, plus about 5 minutes per OAuth provider (most of that is in Google's or GitHub's console).

You need: Node.js 20.12+ and a Voult account.

Package versions: @voult/express 0.2.1+, @voult/sdk 0.2.0+, @voult/react 0.1.0+, @voult/cli 0.2.0+.


What you get

Feature How you get it
Sign up / sign in with email or username + password Built into the router
Sessions in httpOnly cookies, refreshed automatically (1-hour access, 30-day refresh, rotation, reuse detection) Built into the router
Sign out that actually revokes the session on Voult Built into the router
Email verification, forgot/reset password Built into the router (Voult sends the emails)
Sign in with Google, GitHub, Facebook, LinkedIn, Microsoft Built into the router; keys live in the Voult dashboard
Link another provider to a signed-in account Built into the router (intent=link)
MFA (authenticator app + backup codes), enforced for password and OAuth sign-in Verify is built in; setup/enable/disable via @voult/sdk functions
Passkeys (WebAuthn), magic links, session list/revoke, linked accounts @voult/sdk functions you call from your routes
Protect your own API routes requireAuth middleware
React session state, sign-in actions, OAuth buttons @voult/react
Setup checker that catches misconfiguration before users do npx voult doctor
Rate limiting, audit log, encrypted provider secrets, signed OAuth state Done by Voult, nothing to configure

How it works

 Browser (your React app)        Your server (Express)                Voult
 ────────────────────────        ─────────────────────                ─────
 @voult/react                    @voult/express
  useSession() ── /api/auth/session ──► reads httpOnly cookies
  signIn()     ── /api/auth/email-login ─► @voult/sdk ── client ID + secret ──► /api/auth/email-login
  <OAuthButton> ─ /api/auth/oauth/google/start ─► asks Voult for the Google URL ─► signs the request
        ◄──────────── 302 to Google ─────────────┘
  Google consent ──────────────────────────────────────────────────────────────► Voult exchanges the code
        ◄──── 302 /api/auth/oauth/callback?voult_code=…  (one-time, 60 s) ──────┘
                                  exchanges the code, sets cookies,
        ◄──── 302 to your page ──┘

1. Create an app in the Voult dashboard

  1. Open the dashboard at staging.voult.dev/dashboard (Voult is in preview; the SDK talks to this same instance by default), sign up, then Apps → New App.
  2. Copy the Client ID and Client Secret. The secret is shown once; rotate it if you lose it.
  3. On the app page, under Callback URLs, add http://localhost:3000/api/auth/oauth/callback. While the list is empty, localhost is allowed anyway; you'll add your production URL when you deploy.

2. Create the project

mkdir my-app && cd my-app
npm init -y
npm pkg set type=module
npm install express @voult/express @voult/sdk
npm install --save-dev @voult/cli

3. Write your .env

npx voult init

It asks for your Client ID, Client Secret and session strategy (keep cookie), then writes:

VOULT_BASE_URL defaults to the hosted Voult API. Set it only if you run Voult yourself.

4. Mount Voult on your server

// 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());      // all the auth routes below

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

app.listen(3000, () => console.log('http://localhost:3000'));
node --env-file=.env server.js

That's the whole backend integration.

5. Check the setup

npx voult doctor
  ✓ @voult/express 0.2.1, @voult/sdk 0.2.0
  ✓ .env loads and validates
  ✓ VOULT_SESSION_SECRET is set (32+ characters)
  ✓ No provider secrets in .env
  ✓ Voult API at https://… (v1.0.0)
  ✓ @voult/sdk 0.2.0 is supported
  ✓ Client ID and secret accepted (app "My App")
  ⚠ The app has no callback URLs yet, so only localhost works.
  ⚠ No sign-in providers are on, so OAuth buttons will be empty.

Every ✗ comes with the exact fix. Run it again whenever something doesn't work, and in CI with --json.

6. Try it from the terminal

curl -c jar -X POST localhost:3000/api/auth/register -H 'Content-Type: application/json' \
  -d '{"email":"you@example.com","password":"Str0ng!Pass","fullName":"You"}'
curl -b jar -c jar localhost:3000/api/auth/session
curl -b jar -c jar localhost:3000/api/me/orders
curl -b jar -c jar -X POST localhost:3000/api/auth/logout

New accounts get a verification email, and sign-in returns 403 "Please verify your email" until the link is clicked. Passwords need 8+ characters with an uppercase letter, a lowercase letter, a number, and one of @$!%*?&.


7. Add the frontend (React)

npm create vite@latest web -- --template react && cd web
npm install @voult/react

In development, send /api to your server so cookies are same-site:

// web/vite.config.js
export default defineConfig({
  plugins: [react()],
  // changeOrigin: false keeps the browser's Host (localhost:5173). Vite's string shorthand
  // ('/api': 'http://localhost:3000') rewrites it to localhost:3000, which breaks the OAuth callback URL.
  server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: false } } },
});

Then set VOULT_APP_URL=http://localhost:5173 in the server's .env, so OAuth sends users back to the frontend, and add http://localhost:5173/api/auth/oauth/callback under Callback URLs (the server builds the callback from the host the browser used).

// web/src/App.jsx
import { useState } from 'react';
import {
  VoultProvider, useSession, useVoult, OAuthButton, useOAuthProviders, getOAuthRedirectResult,
} from '@voult/react';

function SignIn() {
  const { signIn } = useVoult();
  const { providers } = useOAuthProviders();          // only providers that are ready
  const [error, setError] = useState(getOAuthRedirectResult().error?.description);

  async function onSubmit(e) {
    e.preventDefault();
    const form = new FormData(e.target);
    try {
      await signIn({ email: form.get('email'), password: form.get('password') });
    } catch (err) {
      setError(err.message);                          // e.g. "Invalid email or password"
    }
  }

  return (
    <>
      <form onSubmit={onSubmit}>
        <input name="email" type="email" placeholder="Email" required />
        <input name="password" type="password" placeholder="Password" required />
        <button>Sign in</button>
      </form>
      {providers.map((p) => <OAuthButton key={p} provider={p} returnTo="/" />)}
      {error && <p role="alert">{error}</p>}
    </>
  );
}

function MfaPrompt() {
  const { verifyMfa } = useVoult();
  return (
    <form onSubmit={(e) => { e.preventDefault(); verifyMfa(new FormData(e.target).get('code')); }}>
      <input name="code" inputMode="numeric" autoComplete="one-time-code" placeholder="6-digit code" />
      <button>Verify</button>
    </form>
  );
}

function Home() {
  const { status, user } = useSession();
  const { signOut } = useVoult();
  if (status === 'loading') return <p>Loading…</p>;
  if (status === 'mfa_required') return <MfaPrompt />;   // after password *or* OAuth sign-in
  if (status !== 'authenticated') return <SignIn />;
  return <button onClick={signOut}>Sign out {user.email}</button>;
}

export default function App() {
  return <VoultProvider apiBase="/api/auth"><Home /></VoultProvider>;
}

That's a complete sign-in UI:

Not using React? Call the same routes with fetch(url, { credentials: 'include' }). The voult-demo home page does it in about 40 lines of plain JavaScript.


8. Turn on "Continue with Google" (no code)

  1. Google Cloud Console → APIs & Services → Credentials → Create OAuth client (Web application).
  2. Voult dashboard → your app → Sign-in providers → Google. It shows the redirect URI to copy, e.g. https://api.voult.dev/api/oauth/google/callback. Paste it into Google as an Authorized redirect URI.
  3. Paste Google's Client ID and Client Secret into the Voult form, tick Turn on, and save. The secret is stored encrypted and never shown again.
  4. Reload your app: a "Continue with Google" button appears.

GitHub is the same (GitHub → Settings → Developer settings → OAuth Apps). Facebook, LinkedIn and Microsoft follow the same steps in their own consoles (Google and GitHub are the ones verified end to end today). npx voult doctor tells you if a provider is on but missing credentials.

What users see:

  1. They click the button.
  2. Google asks them to consent.
  3. They land back on your page, signed in. A new account is created on the first visit, and returning users are recognised by email.
  4. If they have MFA on, they're asked for their code first.
  5. If they cancel, your page gets ?voult_error=access_denied.

Linking accounts: while signed in, <OAuthButton provider="github" intent="link" /> adds GitHub to the current account.


9. Every account feature is already a route

MFA setup, sessions, linked accounts, passkeys, magic links and account deletion are all served by the router (@voult/express 0.3+), so you write no auth handlers. From React, useVoult() has a helper for each (@voult/react 0.2+):

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

const { secret, qrCode, backupCodes } = await mfa.setup();   // show the QR code, then:
await mfa.enable(code);
const { sessions: list } = await sessions.list();           // this browser's has isCurrent: true
await sessions.revoke(id);                                   // revoking your own signs you out
await magicLink.send(email, { returnTo: '/account' });       // always { sent: true }

Passkeys need the browser's WebAuthn ceremony between the two calls: pass passkeys.registrationOptions() / passkeys.loginOptions() to navigator.credentials (or @simplewebauthn/browser) and hand the result to passkeys.register() / passkeys.signIn().

For your own API routes, req.voult is the user's @voult/sdk client (see step 4), so any SDK function works there too.


10. Deploy to production

  1. Set the same variables on your host, plus these two:

    • NODE_ENV=production
    • VOULT_APP_URL=https://myapp.com (your frontend)

    In production the server refuses to start without a 32+ character VOULT_SESSION_SECRET.

  2. In the Voult dashboard, add https://myapp.com/api/auth/oauth/callback (or wherever your server is reachable) under Callback URLs.

  3. Behind a proxy, keep app.set('trust proxy', 1). If the computed callback URL is still wrong, set VOULT_OAUTH_CALLBACK_URL.

  4. Add npx voult doctor --json to CI. It exits 1 on any problem, and it catches plain-http callbacks and short secrets in production.


Why it's easy

You'd normally build With Voult
Password hashing, lockout, reset tokens, verification emails Built into the router
Access/refresh tokens, rotation, reuse detection, cookie handling Built into the router
An OAuth client per provider, state/CSRF handling, callback endpoints Voult-hosted; you add a button
MFA (TOTP, backup codes), with MFA enforced on every sign-in path Built in
A login UI state machine useSession() has 4 states
Debugging "why doesn't sign-in work?" npx voult doctor

The whole integration: 2 packages on the server, ~15 lines, 1 React provider, 0 provider secrets in your code.


Route reference

All routes are relative to where you mount the router (/api/auth above). "Auth" means the route needs a signed-in user.

Method Path Auth Notes
GET /session – { authenticated, user, mfaPending? }; renews an expired access cookie
POST /register, /username-register – returns emailVerificationRequired
POST /email-login, /username-login – { mfaRequired: true } when a code is needed
POST /mfa/verify – { mfaToken } (the pending sign-in is in a cookie)
GET /mfa/status ✓
POST /logout ✓ revokes on Voult, clears cookies
POST /sessions/refresh ✓ usually automatic
GET / PATCH /user/me ✓ read / update profile
POST /user/forgot-password, /user/reset-password –
GET /user/verify-email –
GET /oauth/providers – { providers: { google: true, … } }
GET /oauth/:provider/start – (link: ✓) ?intent=authenticate|login|register|link&returnTo=/path
GET /oauth/callback – where Voult sends users back; add it to Callback URLs
POST /mfa/setup ✓ { secret, qrCode, backupCodes }; confirm with /mfa/enable
POST /mfa/enable ✓ { code }
POST /mfa/disable ✓ { code, password? } (no password for Google/GitHub accounts)
POST /mfa/backup-codes ✓ { code } → new backupCodes
POST /mfa/cancel – abandon a sign-in waiting for its code
GET /sessions ✓ { sessions }; this browser's has isCurrent: true
DELETE /sessions/:id ✓ { current }; revoking your own signs this browser out
GET /oauth/linked ✓ { providers }
DELETE /oauth/linked/:provider ✓ unlink
DELETE /user/me ✓ Voult disables the account (it can be re-enabled); signs this browser out
POST /passkeys/register/options ✓ { deviceName? } → WebAuthn creation options
POST /passkeys/register/verify ✓ { credential, deviceName? }
GET /passkeys ✓ list
PATCH / DELETE /passkeys/:id ✓ rename ({ deviceName }) / delete
POST /passkeys/login/options – { email? } → WebAuthn request options
POST /passkeys/login/verify – { credential }; signs in like /email-login
POST /magic-link – { email, returnTo? } → always { sent: true }
GET /magic-link/verify – the emailed link; signs in and redirects like /oauth/callback
GET /session/renew – ?returnTo=/path: renews from the refresh cookie and redirects back (used by @voult/next's requireSession())

Magic links open /magic-link/verify on the same origin as your OAuth callback, so the one callback URL on the app's allowlist covers them.

Where OAuth and magic links send the browser (change with createVoultRouter({ oauth: { successPath, mfaPath, errorPath } })):


Troubleshooting

Run npx voult doctor first. It covers most of these. Every error code the API can return is listed with its fix in ERRORS.md.

Symptom Fix
Missing VOULT_CLIENT_ID at startup Run npx voult init; start with node --env-file=.env server.js
Sign-in works but the session is lost Add credentials: 'include' to fetch, or proxy /api so frontend and server share a host
OAuth error INVALID_CALLBACK_URL Add your server's /api/auth/oauth/callback under Callback URLs
OAuth error INVALID_OAUTH_STATE The sign-in expired (10 min) or started in another browser/tab: try again
OAuth error PROVIDER_NOT_ENABLED / button missing Dashboard → Sign-in providers: turn it on with credentials
Google says redirect_uri_mismatch Paste the exact redirect URI from the Voult provider page into Google's console
403 Please verify your email Click the link in the verification email
OAUTH_REQUIRES_COOKIE_SESSION Hosted OAuth needs VOULT_SESSION_STRATEGY=cookie (the default)

Advanced

Working examples: