BuildBase on NestJS: authentication without Passport, JWTs or a refresh table
NestJS authentication without Passport or JWTs: one guard reads a Bearer session ID, RolesGuard keeps working, and ending a session actually ends it.
In short
To add authentication to a NestJS API without Passport or your own JWTs, use one guard that reads the Bearer token as a BuildBase session ID and sets id, role and sessionId on request.user, so RolesGuard and every @Roles() route keep working. Logout ends the session on BuildBase: no refresh tokens to rotate.
NestJS's own authentication chapter signs a JWT at login and verifies it on every request, locally, with no call anywhere. That is fast, and it has one gap it never closes: nothing can end a token before it expires. Fill that gap yourself and you are building refresh tokens, a table to track them, and a way to revoke them.
Look at what NestJS auth actually does, though, and it is two jobs. A guard
says who is calling. RolesGuard says what they may do. Only the first one is
about credentials, and only the first one needs replacing.
So replace it with one guard. The Bearer token becomes a BuildBase session ID,
BuildBase says who it belongs to, and request.user gets the same
{ id, role } it always had.
We did this to
nestjs-boilerplate,
the 4.4k-star REST starter: src went from 6,268 lines to 4,338, and out went
Passport, @nestjs/jwt, bcrypt and the session table. RolesGuard's logic did
not change.
Before you start
A NestJS API, a BuildBase organization, and an auth client created in the
console under User Management → Authentication, with its client ID and
secret. Register your frontend's callback URL on the client, and in
development the API's own
http://localhost:3001/api/v1/auth/buildbase/callback for trying things in
Swagger.
Tip
Start from the working example.
nestjs-boilerplate-api
in the examples repo runs everything on this page in nestjs-boilerplate.
Watch it
working,
or copy it with npx degit buildbase-app/examples/nestjs-boilerplate-api my-app.
1. One service owns every BuildBase call
npm i @buildbase/sdk// src/auth/buildbase.service.ts (trimmed)
@Injectable()
export class BuildBaseService {
private client?: ReturnType<typeof BuildBase>;
private readonly profiles = new Map<
string,
{ profile: BuildBaseProfile; expires: number }
>();
/** The hosted sign-in URL for a client to send people to. */
async signInUrl(redirect: string, state?: string): Promise<string> {
const { orgId, clientId } = this.requireConfigured();
const { redirectUrl } = await this.authApi().requestAuth({
orgId,
clientId,
redirect: { success: redirect, error: redirect },
state,
});
return redirectUrl;
}
/** Swap the one-time code for a BuildBase session ID, with the secret. */
async exchangeCode(code: string): Promise<string> {
/* POST /api/v1/auth/token */
}
/** The BuildBase user behind a session, cached for a minute, or 401. */
async profile(sessionId: string): Promise<BuildBaseProfile> {
const cached = this.profiles.get(sessionId);
if (cached && cached.expires > Date.now()) return cached.profile;
this.client ??= BuildBase({ serverUrl: this.serverUrl, orgId: this.orgId });
const user = await this.client
.withSession(sessionId)
.users.getProfile()
.catch(() => {
throw new UnauthorizedException();
});
const id = user.id ?? user._id;
if (!id || !user.email) throw new UnauthorizedException();
const profile = { id: String(id), email: user.email, name: user.name };
this.profiles.set(sessionId, { profile, expires: Date.now() + 60_000 });
return profile;
}
/** Forget the session here, and end it on BuildBase. */
async revoke(sessionId: string) {
this.profiles.delete(sessionId);
await this.authApi()
.logout(sessionId)
.catch(() => {});
}
}Nest has no async request context of its own, so the SDK is bound per call with
withSession(), the same way Express and
Fastify bind it. The one-minute cache means a
busy API asks BuildBase about once a minute per session per process, instead
of on every request. The example's version also drops expired entries as it
goes, so the map holds only recent sessions.
2. The guard: Bearer token in, request.user out
// src/auth/buildbase-auth.guard.ts
@Injectable()
export class BuildBaseAuthGuard implements CanActivate {
constructor(private readonly authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const [scheme, token] = (request.headers.authorization ?? '').split(' ');
if (scheme !== 'Bearer' || !token) throw new UnauthorizedException();
request.user = await this.authService.userForSession(token);
return true;
}
}// src/auth/auth.service.ts
async userForSession(sessionId: string) {
const user = await this.resolveUser(await this.buildbase.profile(sessionId));
return { id: user.id, role: user.role, sessionId };
}That return value is the whole trick. It is the shape the JWT strategy used to put on the request, so every controller that stacks the guards keeps reading it unchanged:
@Roles(RoleEnum.admin)
@UseGuards(BuildBaseAuthGuard, RolesGuard)
@Controller({ path: 'users', version: '1' })
export class UsersController {
/* upstream's, untouched */
}The only edits in controllers like this are the guard's name and its import,
where AuthGuard('jwt') used to be. And the role still comes from your
users table on every request, so promoting someone to admin takes effect on
their next call. If you would rather keep roles out of your database,
the Astro setup reads a BuildBase workspace role
on every request instead.
resolveUser finds the user by BuildBase ID (stored where the boilerplate kept
social logins, as socialId with provider buildbase), adopts an existing
user by email, or creates one with the user role. The migration drops the
session table and the password column.
3. Two endpoints for sign-in
An API has no pages, so the client drives sign-in:
GET /api/v1/auth/buildbase/url?redirect=<your page>&state=<random>returns{ url }, the hosted sign-in page. The client keeps thestateand sends the person there.- BuildBase returns to your page with
?code=and thestate. The client refuses astateit did not issue, then posts the code toPOST /api/v1/auth/buildbase/login, and gets back{ token, user }. - From then on:
Authorization: Bearer <token>.
BuildBase checks the redirect against the URLs registered on the auth
client, so nobody can point it at an arbitrary site.
In development, GET /api/v1/auth/buildbase/callback exchanges the code itself
and shows the token on a page (it skips the state check, which is the
client's job). That is how you try the API in Swagger with no
frontend at all. In production it answers 404.
Try it: open the URL from step 1, sign in, paste the token into Swagger's
Authorize, and GET /api/v1/auth/me returns your user with
provider: "buildbase".
Key takeaway
In NestJS, BuildBase replaces the guard that says who is calling. RolesGuard,
which says what they may do, keeps reading the same { id, role } from
your own database.
4. Logout that actually logs out
@Post('logout')
@UseGuards(BuildBaseAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
logout(@Request() request) {
return this.authService.logout(request.user); // reads .sessionId, then buildbase.revoke()
}This is the part JWTs make hard. A signed token stays valid until it expires,
whatever you do on the server, which is why JWT setups end up with refresh
tokens and blocklists. Here the token is a session ID, and revoke() ends the
session on BuildBase. The same token gets a 401 on the next request to the
instance that handled the logout.
The catch: other instances of your API can still hold that profile in their
one-minute cache, so a revoked token works there for up to a minute. If a
minute is too long, shorten the TTL or share the cache. And revoke() ignores
a failed call to BuildBase, so if logout cannot reach it, the session lives on
until it expires.
Where plain JWTs are simpler
NestJS's own authentication chapter verifies a JWT locally, with no network
call at all. It also registers its guard globally and marks the exceptions
with a @Public() decorator, so a new controller is protected until someone
opts it out. The example does neither: it applies the guard per controller, as
the boilerplate already did, and calls BuildBase on a cache miss.
If your API cannot make an outbound call on the auth path, or you never need to end a session early, a self-verified JWT has fewer moving parts.
What breaks, and how you can tell
Sign-in and every protected route answer 503. BUILDBASE_ORG_ID, BUILDBASE_CLIENT_ID or
BUILDBASE_CLIENT_SECRET is missing, and the service refuses to guess.
A logged-out token still works for a minute. A different instance served it from its cache. See the limit above.
Seeded users cannot sign in. They have no BuildBase account yet. Adoption by email happens the first time someone signs in through BuildBase with that address, role and all.
GET auth/buildbase/url fails. BuildBase refused the redirect: it must
match a URL registered on the auth client 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,
but not for a public suffix like *.vercel.app.
Install
Add the SDK to your own app, or start from the example with
npx degit buildbase-app/examples/nestjs-boilerplate-api my-app.
npm i @buildbase/sdk