Guides
Build guide

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.

Dharmendra Jagodana5 min read

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:

  1. GET /api/v1/auth/buildbase/url?redirect=<your page>&state=<random> returns { url }, the hosted sign-in page. The client keeps the state and sends the person there.
  2. BuildBase returns to your page with ?code= and the state. The client refuses a state it did not issue, then posts the code to POST /api/v1/auth/buildbase/login, and gets back { token, user }.
  3. 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
nestjs
api
auth

Frequently Asked Questions

Do I need Passport for NestJS authentication?

No. NestJS's own authentication chapter builds a plain guard first and brings in @nestjs/passport only as an optional integration. A guard is a CanActivate class; this one reads a Bearer token and asks BuildBase who it belongs to.

Does my existing RolesGuard still work?

Yes. The guard puts request.user in the same { id, role } shape the JWT strategy used to, and the role still comes from your own users table on every request. In the nestjs-boilerplate swap, RolesGuard's logic did not change and every @Roles() route kept working.

How do I revoke a token without a refresh-token table?

The token is a BuildBase session ID, not a self-contained JWT, so ending the session on BuildBase makes it invalid. The example caches each profile lookup for a minute per process, so another instance of the API can accept a revoked token for up to that minute.

How do I try the API in Swagger without a frontend?

In development, the example serves a small callback page that finishes the hosted sign-in and shows the API token, ready to paste into Swagger's Authorize dialog. It answers 404 in production.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime