React SPA + backend proxy
Use sso-frappe/browser in the React app and sso-frappe/server in your backend (Express, NestJS, Hono, Fastify, ...).
React does not talk to Frappe's token endpoint directly. The backend keeps
clientSecretserver-side.
Architecture
React button/page
│ startLogin()
▼
Frappe authorize page
│ redirect back with code + state
▼
React callback page
│ POST { code, codeVerifier }
▼
Your backend
│ exchangeCode() + getUserProfile()
▼
Create local session/cookie/JWTFrontend env vars
VITE_FRAPPE_SSO_BASE_URL=https://erp.example.com
VITE_FRAPPE_SSO_CLIENT_ID=<oauth-client-id>
VITE_FRAPPE_SSO_REDIRECT_URI=http://localhost:5173/auth/callback
clientSecretis intentionally absent here.
1. Login button
// src/components/LoginWithFrappeButton.tsx
import { createFrappeSSOBrowser } from 'sso-frappe/browser';
const sso = createFrappeSSOBrowser({
baseUrl: import.meta.env.VITE_FRAPPE_SSO_BASE_URL,
clientId: import.meta.env.VITE_FRAPPE_SSO_CLIENT_ID,
redirectUri: import.meta.env.VITE_FRAPPE_SSO_REDIRECT_URI,
allowInsecureHttp: true, // local dev only
});
export function LoginWithFrappeButton() {
return (
<button
onClick={() => sso.startLogin()}
className="rounded-lg bg-slate-900 px-4 py-2 text-white"
>
Login with Frappe
</button>
);
}2. Callback page
// src/pages/FrappeCallbackPage.tsx
import { useEffect, useState } from 'react';
import { createFrappeSSOBrowser } from 'sso-frappe/browser';
import { useNavigate } from 'react-router-dom';
const sso = createFrappeSSOBrowser({
baseUrl: import.meta.env.VITE_FRAPPE_SSO_BASE_URL,
clientId: import.meta.env.VITE_FRAPPE_SSO_CLIENT_ID,
redirectUri: import.meta.env.VITE_FRAPPE_SSO_REDIRECT_URI,
allowInsecureHttp: true,
});
export default function FrappeCallbackPage() {
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
const { code, codeVerifier } = await sso.handleCallback();
const response = await fetch('/api/auth/frappe/callback', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code, codeVerifier }),
});
if (!response.ok) {
throw new Error('SSO callback failed');
}
sso.clear();
navigate('/dashboard');
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown SSO error');
}
})();
}, [navigate]);
if (error) {
return <p>Login failed: {error}</p>;
}
return <p>Finishing login...</p>;
}3. Express backend
import express from 'express';
import session from 'express-session';
import { createFrappeSSO } from 'sso-frappe/server';
const app = express();
app.use(express.json());
app.use(
session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: false, // local dev only
},
}),
);
const sso = createFrappeSSO({
baseUrl: process.env.FRAPPE_SSO_BASE_URL!,
clientId: process.env.FRAPPE_SSO_CLIENT_ID!,
clientSecret: process.env.FRAPPE_SSO_CLIENT_SECRET!,
redirectUri: process.env.FRAPPE_SSO_REDIRECT_URI!,
allowInsecureHttp: true,
});
app.post('/api/auth/frappe/callback', async (req, res) => {
try {
const { code, codeVerifier } = req.body as {
code?: string;
codeVerifier?: string;
};
if (!code || !codeVerifier) {
return res.status(400).json({ message: 'Missing code or codeVerifier' });
}
const token = await sso.exchangeCode({ code, codeVerifier });
const profile = await sso.getUserProfile(token);
// Match/create local user (see docs/database-schema.md)
const user = await upsertUserFromFrappe(profile);
req.session.userId = user.id;
return res.status(200).json({ ok: true });
} catch (err) {
console.error(err);
return res.status(400).json({ message: 'SSO callback failed' });
}
});Recommended user matching
- Look up
user_identities(provider='frappe', provider_subject=profile.subject) - If missing, fallback to
users.email = profile.email - If email exists, insert the
frappeidentity row and link it - If still missing, create
usersrow +user_identitiesrow - Create the app session after the local user is resolved
See database schema for the full schema.
NestJS / Fastify / Hono / Koa
Same flow. Only route syntax changes:
- NestJS: controller method + session middleware
- Fastify:
fastify.post('/api/auth/frappe/callback', async (request, reply) => ...) - Hono:
app.post('/api/auth/frappe/callback', async (c) => ...)
The sso-frappe/server client code is identical.
Common mistakes
| Mistake | Why it breaks |
|---|---|
Putting FRAPPE_SSO_CLIENT_SECRET in VITE_* or NEXT_PUBLIC_* |
Exposes the secret in the browser bundle |
Doing exchangeCode() in React |
Same problem — requires the secret |
| Matching users by email only | Email can change in Frappe; always prefer provider_subject |
| Auto-creating a password string you expect users to log in with | SSO users often should have password = NULL, or a random hash if the DB column is NOT NULL |
Optional: central /start route
If you prefer your backend to own state + redirect (instead of the SPA calling startLogin()), you can expose /api/auth/frappe/start and let the button simply do window.location.href = '/api/auth/frappe/start'. That backend route generates { url, state, codeVerifier }, stores the state/verifier in a server-side session, then redirects to Frappe. Both patterns are valid.