BuildBase on Express: add authentication to an existing app and keep req.user
Add authentication to an existing Express app: hosted sign-in, a callback route, and express-session, with req.user and every protected route left untouched.
In short
To add authentication to an existing Express app with BuildBase, have POST /login redirect to hosted sign-in, then swap the returned code for a session on a callback route using the client secret. Keep the session ID in express-session and call req.logIn(), so req.user and every protected route keep working unchanged.
Passport was never the problem in your Express app. The problem is everything built around it: the local strategy, bcrypt, reset tokens, verification emails, the passkey ceremony, the TOTP setup page. That is where the lines are, and where the security bugs hide.
So keep Passport and delete the rest. On
hackathon-starter,
the 35k-star Express template, controllers/user.js and controllers/webauthn.js
were 1,384 lines between them. They are now one user.js of about 240 lines. Every route
that reads req.user is unchanged, and all 133 of upstream's remaining tests
pass.
Before you start
An Express app using express-session (with or without Passport), a BuildBase
organization, and an auth client created in the console under User
Management → Authentication, with its client ID and secret. Register
http://localhost:8080/auth/buildbase/callback (hackathon-starter's port) on
the client as a redirect URL.
Tip
Start from the working example.
hackathon-starter
in the examples repo runs everything on this page in Hackathon Starter. Watch
it
working,
or copy it with npx degit buildbase-app/examples/hackathon-starter my-app.
1. Install the SDK and add three variables
npm i @buildbase/sdk# .env
BUILDBASE_ORG_ID=demo-org-id
BUILDBASE_CLIENT_ID=demo-client-id
BUILDBASE_CLIENT_SECRET=demo-client-secret
BASE_URL=http://localhost:8080
# BUILDBASE_SERVER_URL defaults to https://api.console.buildbase.app2. One module for everything BuildBase
// config/buildbase.js
const crypto = require('node:crypto');
const { AuthApi, BuildBase } = require('@buildbase/sdk');
const SERVER_URL =
process.env.BUILDBASE_SERVER_URL || 'https://api.console.buildbase.app';
const authApi = new AuthApi({ serverUrl: SERVER_URL, version: 'v1' });
const callbackUrl = () => `${process.env.BASE_URL}/auth/buildbase/callback`;
// Made on first use: BuildBase() refuses to start without an org ID.
let client;
const buildbase = () => {
client ??= BuildBase({
serverUrl: SERVER_URL,
orgId: process.env.BUILDBASE_ORG_ID,
});
return client;
};
/** The signed-in user's BuildBase session, bound for this request. */
exports.forRequest = (req) =>
buildbase().withSession(req.session?.buildbaseSessionId);
/** Remember a random state, then send the visitor to the hosted page. */
exports.startSignIn = async (req, res) => {
const state = crypto.randomBytes(16).toString('hex');
req.session.buildbaseState = state;
const { redirectUrl } = await authApi.requestAuth({
orgId: process.env.BUILDBASE_ORG_ID,
clientId: process.env.BUILDBASE_CLIENT_ID,
redirect: { success: callbackUrl(), error: callbackUrl() },
state,
});
res.redirect(redirectUrl);
};
/** Swap the one-time code for a session ID. Needs the client secret. */
exports.exchangeCode = async (code) => {
const res = await fetch(`${SERVER_URL}/api/v1/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code,
orgId: process.env.BUILDBASE_ORG_ID,
clientId: process.env.BUILDBASE_CLIENT_ID,
clientSecret: process.env.BUILDBASE_CLIENT_SECRET,
}),
});
if (!res.ok)
throw new Error(`BuildBase token exchange failed (${res.status})`);
return (await res.json()).data.sessionId;
};
/** Ends the BuildBase session itself, not just this app's. */
exports.revokeSession = async (sessionId) => {
if (sessionId) await authApi.logout(sessionId).catch(() => {});
};Then point your login form at it. POST /login becomes one line,
buildbase.startSignIn(req, res), and the login page becomes a single button.
Click it and you land on BuildBase's hosted page, offering whichever of the
eight sign-in methods your org has switched on.
3. The callback: state, code, user, req.logIn()
// controllers/user.js
exports.getBuildbaseCallback = async (req, res, next) => {
const { code, state } = req.query;
const expectedState = req.session.buildbaseState;
req.session.buildbaseState = undefined;
if (typeof code !== 'string' || !state || state !== expectedState) {
return res.redirect('/login');
}
try {
req.session.buildbaseSessionId = await buildbase.exchangeCode(code);
const profile = await buildbase.forRequest(req).users.getProfile();
const rawId = profile.id || profile._id;
// Never String() a missing ID: every such user would share one account.
if (!rawId || !profile.email)
throw new Error('BuildBase returned a profile without an ID or email.');
const buildbaseId = String(rawId);
let user = await User.findOne({ buildbase: { $eq: buildbaseId } });
if (!user) {
// An account made before BuildBase, with the same email, is adopted.
user =
(await User.findOne({ email: { $eq: profile.email } })) ||
new User({ email: profile.email });
user.buildbase = buildbaseId;
}
await user.save();
// passport.session() still carries the user between requests.
req.logIn(user, (err) =>
err ? next(err) : res.redirect(req.session.returnTo || '/')
);
} catch (err) {
next(err);
}
};// app.js
app.get(
'/auth/buildbase/callback',
loginLimiter,
userController.getBuildbaseCallback
);The user model gains one field:
buildbase: { type: String, unique: true, sparse: true }. And the $eq in
both queries is not decoration. It stops a crafted query string from becoming
a Mongo operator.
That req.logIn(user) is the whole trick. It is the same call your local
strategy ended with, so passport.session() keeps putting the user on
req.user, and isAuthenticated middleware, templates and every route that
reads req.user never find out the sign-in moved.
Require the state, do not just compare it when present. A check written as
state && state !== expected waves through any callback that omits it. We
shipped exactly that in this example and fixed it while writing this page.
To check it: open a protected page signed out, sign in on the hosted page, and you should land back on that page with your name in the header.
4. Call BuildBase from any route, as the user
// controllers/buildbase.js
const crypto = require('node:crypto');
exports.postCredit = async (req, res, next) => {
try {
const bb = buildbase.forRequest(req);
const [workspace] = await bb.workspace.list();
const result = await bb.credits.consume(workspace._id, {
amount: 1,
description: 'API example',
// Minted when the form was rendered. Check the type: a crafted body can send an object.
idempotencyKey:
typeof req.body.key === 'string' ? req.body.key : crypto.randomUUID(),
});
req.flash('success', {
msg: `Spent 1 credit. ${result.balanceAfter} left.`,
});
res.redirect('/api/buildbase');
} catch (err) {
if (err.code === 'INSUFFICIENT_CREDITS') {
req.flash('errors', { msg: 'Out of credits.' });
return res.redirect('/api/buildbase');
}
next(err);
}
};forRequest(req) is the Express answer to "how does the SDK know who is
calling?". There is no async request context to lean on, so each route binds
the session it already has, the same way
a React Router loader does and
a Fastify plugin does. The idempotency key
rides in the form, so a double-submitted POST spends one credit, not two.
Workspaces, plans and credits all follow that one pattern. The credits wallet guide and charging per credit are written for Next.js, but from the SDK call down they are the same code in Express.
5. Logout ends both sessions
exports.logout = async (req, res) => {
await buildbase.revokeSession(req.session?.buildbaseSessionId);
req.logout(() => req.session.destroy(() => res.redirect('/')));
};Skip the revoke and you are signed out of your app, but the BuildBase session stays alive: anything else holding it keeps working until it expires.
And what Passport is still for
Passport stays, but its job shrinks. Hackathon-starter's API examples call GitHub, Google, Facebook and others on the user's behalf, and that needs the provider's OAuth token. So the provider strategies stay, for linking: a signed-in user connects GitHub from their account page. Signing in with GitHub happens on the hosted page instead, and a strategy callback that finds no signed-in user now answers "sign in first, then link it".
Key takeaway
In Express, BuildBase proves who someone is, then req.logIn() hands them to
Passport exactly as your old login did. Nothing downstream of req.user
changes.
Where Auth0 is less work
If you are starting fresh, Auth0's
express-openid-connect middleware is shorter: its auth() mounts /login,
/logout and /callback for you. Here you write those three yourself, about
80 lines of code across config/buildbase.js and the callback. The trade is control. Because the
callback is yours, it is also where your existing users get adopted by email,
and where BuildBase's session sits next to your own for the workspace and
credit calls that follow.
What breaks, and how you can tell
Every sign-in lands back on /login. The state is not surviving the round
trip. express-session's cookie must be SameSite=Lax or unset, not
Strict. The hosted page sends the visitor back
with a top-level navigation from another site, and browsers drop a strict
cookie on that. Behind a proxy with a secure cookie, also check
app.set('trust proxy', 1).
forRequest(req) calls fail with 401 after a restart. Your session store
is in memory, so the BuildBase session ID went with it. Use a persistent store
(the example uses MongoDB) or expect everyone to sign in again.
Credit spends start failing with 429 under load. Credit consumption is limited to 30 requests a minute per IP, and the IP it sees is your server's, not your user's. A busy app has to spend in batches rather than per click.
The hosted page refuses to send you back. The callback URL must match a
registered one exactly, path included; the port must match too, except on
localhost, where it is ignored. A wildcard can stand in for one leftmost subdomain on a domain you own
(https://*.app.example.com/...), but not for a public suffix, so
*.vercel.app is refused: register each preview domain you use.
Install
Add the SDK to your own app, or start from the example with
npx degit buildbase-app/examples/hackathon-starter my-app.
npm i @buildbase/sdk