SPA + serverless (Vercel)
A guide to adding logi login to an app with no backend of its own, such as a Vite / CRA / static-build SPA. Because serverless functions (/api/auth/*) handle the token exchange, this app is a Confidential client — client_secret lives in the function's environment variables, not in the browser.
📋 Register as a Confidential client
The secret is held by serverless functions, so this is Confidential. Only a pure SPA (zero server code) where the browser calls /oauth/token directly is Public.
Architecture
[Browser SPA] login click → GET /api/auth/login (serverless)
│ │ generate PKCE + state → temporarily store in httpOnly cookie
│ ▼ 302
│ {logi}/oauth/authorize
│ │ user authentication & consent
▼ ▼ 302 ?code&state
[Serverless] GET /api/auth/callback
│ verify state → POST {logi}/oauth/token (client_secret_basic)
│ sub from id_token + profile from userinfo → issue a signed session cookie (httpOnly) → 302 /
▼
[Browser] GET /api/auth/me → { user } (based on the session cookie)The key point: the logi access/refresh token is visible only inside the serverless functions, and the browser holds only the app's own session cookie. This is a stateless RP pattern that stores no tokens.
1. RP registration
- Client type: Confidential
- Redirect URI:
https://your-app.vercel.app/api/auth/callback - Scopes:
openid,profile:basic,email
client_secret is shown only once, right after you save.
2. Environment variables (Vercel project settings)
ONE_PASS_URL=https://api.1pass.dev
ONE_PASS_CLIENT_ID=logi_...
ONE_PASS_CLIENT_SECRET=... # accessed only in serverless functions, never exposed to the browser
ONE_PASS_SCOPES=openid profile:basic email
SESSION_SECRET=<openssl rand -hex 32> # for HMAC-signing the session cookie — requiredSESSION_SECRET must be set in production
The session cookie is HMAC-signed with this value. Without it, the cookie can be forged to impersonate any user, so in production make write requests fail-closed (blocked) when it is unset.
3. Shared utilities (api/_lib.js)
PKCE generation, the HMAC-signed session cookie, and the token exchange are all handled with Node's built-in crypto, without external dependencies.
import crypto from 'node:crypto';
export const LOGI = {
base: process.env.ONE_PASS_URL || 'https://api.1pass.dev',
clientId: process.env.ONE_PASS_CLIENT_ID || '',
clientSecret: process.env.ONE_PASS_CLIENT_SECRET || '',
scopes: process.env.ONE_PASS_SCOPES || 'openid profile:basic email',
get authorizeUrl() { return `${this.base}/oauth/authorize`; },
get tokenUrl() { return `${this.base}/oauth/token`; },
};
const SESSION_SECRET = process.env.SESSION_SECRET || 'dev-insecure-change-me';
const SESSION_COOKIE = 'app_session';
const SESSION_MAX_AGE = 60 * 60 * 24 * 14; // 14 days
const b64url = (b) => Buffer.from(b).toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const fromB64urlJson = (s) => {
try { return JSON.parse(Buffer.from(s.replace(/-/g,'+').replace(/_/g,'/'),'base64').toString()); }
catch { return null; }
};
// PKCE
export function generatePkce() {
const verifier = b64url(crypto.randomBytes(32));
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
return { verifier, challenge };
}
export const randomState = () => b64url(crypto.randomBytes(24));
// HMAC-signed session (payload.signature)
const sign = (p) => crypto.createHmac('sha256', SESSION_SECRET).update(p).digest('base64url');
export function signSession(data) {
const payload = b64url(JSON.stringify({ ...data, iat: Math.floor(Date.now()/1000) }));
return `${payload}.${sign(payload)}`;
}
export function verifySession(token) {
if (!token || !token.includes('.')) return null;
const [payload, sig] = token.split('.');
const expected = sign(payload);
if (sig.length !== expected.length) return null;
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
return fromB64urlJson(payload);
}
// cookies
export function parseCookies(req) {
const out = {};
(req.headers.cookie || '').split(';').forEach((p) => {
const i = p.indexOf('='); if (i > -1) out[p.slice(0,i).trim()] = decodeURIComponent(p.slice(i+1).trim());
});
return out;
}
function appendHeader(res, k, v) {
const prev = res.getHeader(k);
res.setHeader(k, !prev ? v : Array.isArray(prev) ? [...prev, v] : [prev, v]);
}
export function setCookie(res, name, value, { maxAge } = {}) {
const parts = [`${name}=${encodeURIComponent(value)}`, 'Path=/', 'SameSite=Lax', 'Secure', 'HttpOnly'];
if (maxAge !== undefined) parts.push(`Max-Age=${maxAge}`);
appendHeader(res, 'Set-Cookie', parts.join('; '));
}
export function clearCookie(res, name) {
appendHeader(res, 'Set-Cookie', `${name}=; Path=/; Max-Age=0; SameSite=Lax; Secure; HttpOnly`);
}
export const setSession = (res, user) => setCookie(res, SESSION_COOKIE, signSession({ user }), { maxAge: SESSION_MAX_AGE });
export const getSession = (req) => verifySession(parseCookies(req)[SESSION_COOKIE])?.user || null;
export const clearSession = (res) => clearCookie(res, SESSION_COOKIE);
// decode the id_token payload (received directly over TLS from the token endpoint → no need to re-verify the signature)
export function decodeIdToken(idToken) {
if (!idToken || idToken.split('.').length < 2) return null;
return fromB64urlJson(idToken.split('.')[1]);
}4. Login start (api/auth/login.js)
import { LOGI, generatePkce, randomState, setCookie } from '../_lib.js';
export default function handler(req, res) {
const { verifier, challenge } = generatePkce();
const state = randomState();
const proto = (req.headers['x-forwarded-proto'] || 'https').split(',')[0];
const host = req.headers['x-forwarded-host'] || req.headers.host;
const redirectUri = `${proto}://${host}/api/auth/callback`;
// one-time cookies kept only for the round-trip (10 minutes)
setCookie(res, 'logi_state', state, { maxAge: 600 });
setCookie(res, 'logi_verifier', verifier, { maxAge: 600 });
setCookie(res, 'logi_redirect', redirectUri, { maxAge: 600 });
const params = new URLSearchParams({
response_type: 'code',
client_id: LOGI.clientId,
redirect_uri: redirectUri,
scope: LOGI.scopes, // includes openid → id_token issued
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
res.writeHead(302, { Location: `${LOGI.authorizeUrl}?${params}` });
res.end();
}5. Callback — token exchange + session issuance (api/auth/callback.js)
import { LOGI, parseCookies, clearCookie, setSession, decodeIdToken } from '../_lib.js';
export default async function handler(req, res) {
const { code, state, error, error_description } = req.query;
const cookies = parseCookies(req);
['logi_state','logi_verifier','logi_redirect'].forEach((c) => clearCookie(res, c));
const fail = (m) => { res.writeHead(302, { Location: `/?auth_error=${encodeURIComponent(m)}` }); res.end(); };
if (error) return fail(error_description || error);
if (!code) return fail('missing_code');
if (!state || state !== cookies.logi_state) return fail('state_mismatch'); // CSRF defense
const verifier = cookies.logi_verifier, redirectUri = cookies.logi_redirect;
if (!verifier || !redirectUri) return fail('session_expired');
// Confidential client: the secret is required. If missing, it is a deployment misconfiguration → fail-closed.
if (!LOGI.clientSecret) return fail('server_misconfigured_no_secret');
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(`${LOGI.clientId}:${LOGI.clientSecret}`).toString('base64')}`,
};
const body = new URLSearchParams({
grant_type: 'authorization_code', code, redirect_uri: redirectUri,
client_id: LOGI.clientId, code_verifier: verifier,
});
const tokenRes = await fetch(LOGI.tokenUrl, { method: 'POST', headers, body });
const raw = await tokenRes.text();
if (!tokenRes.ok) return fail(`token_exchange_${tokenRes.status}`);
const tokens = JSON.parse(raw);
// sub comes from the id_token (openid scope required); the profile (email/name) comes from userinfo.
// The logi id_token carries only OIDC-standard claims (sub/iss/aud/exp/iat/at_hash) and
// does not include email or name, so the profile must be fetched with a userinfo call.
const claims = decodeIdToken(tokens.id_token) || {};
let profile = {};
if (tokens.access_token) {
const ui = await fetch(`${LOGI.base}/oauth/userinfo`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
if (ui.ok) profile = await ui.json(); // even on failure, login proceeds (name simply left blank)
}
// An anonymous account may return anonymous:true + a synthetic email
// (anon+<hash>@1pass.internal) in userinfo. It is not a real address, so discard it and
// identify the account by the anonymous flag only (do not infer from the presence of name/email).
const isAnon = profile.anonymous === true;
const synthetic = typeof profile.email === 'string' && profile.email.endsWith('@1pass.internal');
const user = {
sub: claims.sub || profile.sub || null,
anonymous: isAnon,
email: isAnon || synthetic ? null : profile.email || null,
name: isAnon ? null : profile.name || profile.nickname || null, // userinfo: name=full_name, nickname=preferred_name
};
if (!user.sub) return fail('no_subject_in_id_token'); // falls through here if openid is missing
setSession(res, user);
res.writeHead(302, { Location: '/?auth=ok' });
res.end();
}sub from id_token, profile from userinfo
sub (the stable user identifier) can be trusted directly from the id_token payload received over TLS from /oauth/token (the OIDC §3.1.3.7 #6 exception — no signature re-verification needed). But because email and name are not in the id_token, you fetch them by calling /oauth/userinfo with the access_token. The userinfo keys are email, name (=full_name), and nickname (=preferred_name).
Anonymous account / consent / unset — do not assume email and name are "always present"
Even for the same "logged-in user," the case varies:
- Anonymous logi account: userinfo may carry
anonymous: true+ a synthetic email (anon+<hash>@1pass.internal) — not a real address. Identify by theanonymousboolean and discard the synthetic email. - per-claim consent: even if you request the
email/profile:basicscope, if the user does not select that claim on the consent screen, the key is omitted from the response. scope ≠ guaranteed receipt. - Email unset: a Hide-My-Email / SSO-only user may not have an
emailat all.
If you truly need identity, key on sub (always present) and treat email and name as optional information.
JWKS signature verification is required only when the id_token arrives over an untrusted path (front-channel, or stored and reused).
6. Session lookup / logout
// api/auth/me.js
import { getSession } from '../_lib.js';
export default function handler(req, res) {
res.setHeader('Cache-Control', 'no-store');
res.status(200).json({ user: getSession(req) });
}// api/auth/logout.js
import { clearSession } from '../_lib.js';
export default function handler(req, res) {
clearSession(res);
if (req.method === 'GET') { res.writeHead(302, { Location: '/' }); res.end(); return; }
res.status(200).json({ ok: true });
}A stateless RP does not need revoke
This pattern does not store the logi access/refresh token, so on logout you only clear your own session cookie. A /oauth/revoke call is needed only when you store tokens on the server.
7. Using it from the browser
// login: navigate to the serverless route
location.href = '/api/auth/login';
// look up the session on mount
const { user } = await fetch('/api/auth/me', { credentials: 'same-origin' }).then(r => r.json());
// logout
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });8. SPA rewrite (vercel.json)
Route every path except /api/* to index.html (client-side routing).
{
"framework": "vite",
"rewrites": [{ "source": "/((?!api/).*)", "destination": "/index.html" }]
}Troubleshooting
| Symptom | Cause |
|---|---|
no_subject_in_id_token | openid missing from scope → no id_token issued. Check ONE_PASS_SCOPES |
| Name empty after login | profile:basic not requested, consent not checked, or the user has no name set → UI fallback |
Email is anon+...@1pass.internal | An anonymous account's synthetic email → identify via anonymous:true, do not display it |
invalid_client | Confidential but secret missing, or sending a secret to a Public RP |
state_mismatch | One-time cookie expired (over 10 minutes) or cookies blocked |
| Write API returns 503 | SESSION_SECRET unset in production → fail-closed |
Related docs
- Quickstart · Public vs Confidential
- Scope reference · JWKS verification · PKCE
- Next.js — App Router Route Handlers version