Guides
Build guide

BuildBase on Fastify: authentication as a decorator plugin, tests included

Fastify authentication as a decorator plugin: hosted sign-in, @fastify/session, unchanged role checks, a fake for tests, and credits metered per request.

Dharmendra Jagodana5 min read

In short

To add authentication to a Fastify app with BuildBase, register a plugin that decorates fastify.buildbase, sign in through the hosted page, and store the user in @fastify/session in the shape your role checks already read. Tests pass a fake through the plugin options, and paid routes spend credits and answer 402.

The Fastify authentication guides we read (the plugin READMEs, Auth0's quickstart, the tutorials that rank) all stop at the same line: the user is logged in. None of them shows the part that decides whether the integration survives a year: can npm test still run with the network off?

In Fastify the answer is built into the framework. Authentication is a decorator, decorators come from plugins, and plugins take options. So put every call to your identity provider behind fastify.buildbase, and let the test helper hand the app a fake. We did that to the official Fastify demo, the Fastify team's own reference app. Its role checks did not change. Its 57 tests run on MySQL, against the fake.

Then we made one route cost money. Once you know who is calling, the next question is what they pay.

Before you start

A Fastify app with @fastify/session, a BuildBase organization, and an auth client created in the console under User Management → Authentication, with its client ID and secret. Register http://localhost:3000/api/auth/buildbase/callback on the client as a redirect URL.

Tip

Start from the working example. fastify-demo in the examples repo runs everything on this page in the official Fastify demo. Watch it working, or copy it with npx degit buildbase-app/examples/fastify-demo my-app.

1. A plugin that owns every BuildBase call

// src/plugins/app/buildbase.ts
import { ApiVersion, AuthApi, BuildBase } from '@buildbase/sdk';
import { FastifyInstance, FastifyPluginOptions } from 'fastify';
import fp from 'fastify-plugin';

export interface BuildBaseGateway {
  signInUrl(state: string): Promise<string>;
  exchangeCode(code: string): Promise<string>;
  profile(
    sessionId: string
  ): Promise<{ id: string; email: string; name?: string }>;
  account(sessionId: string): Promise<{
    profile: object;
    workspace: object | null;
    credits: number | null;
  }>;
  spend(
    sessionId: string,
    amount: number,
    description: string,
    idempotencyKey: string
  ): Promise<{ balanceAfter: number }>;
  revoke(sessionId: string): Promise<void>;
}

declare module 'fastify' {
  interface FastifyInstance {
    buildbase: BuildBaseGateway;
  }
}

export default fp(
  async (fastify: FastifyInstance, opts: FastifyPluginOptions) => {
    // Tests pass a fake here; everything else gets the real thing.
    fastify.decorate('buildbase', opts.buildbase ?? createGateway(fastify));
  },
  { name: 'buildbase' }
);

createGateway is the real implementation: AuthApi.requestAuth() for the hosted URL, a fetch to /api/v1/auth/token with the client secret for the code exchange, and BuildBase({ serverUrl, orgId }).withSession(sessionId) for everything after.

Fastify core has no async request context (@fastify/request-context adds one), so each call binds the session it is given, the same way Express binds it per request and a NestJS guard does. The client is made on first use, which lets the API boot and serve its docs before the BuildBase variables are filled in. The example's plugin is about 140 lines, most of them createGateway.

Why an interface instead of calling the SDK in routes? Because the interface is the seam. Routes depend on six methods, not on a network.

2. Sign-in: two routes and a session shape you already have

// src/routes/api/auth/index.ts
fastify.get('/buildbase/sign-in', async (request, reply) => {
  const state = randomBytes(16).toString('hex');
  request.session.buildbaseState = state;
  await request.session.save();
  return reply.redirect(await fastify.buildbase.signInUrl(state));
});

fastify.get('/buildbase/callback', async (request, reply) => {
  const { code, state } = request.query;
  const expected = request.session.buildbaseState;
  request.session.buildbaseState = undefined;
  if (!code || !sameState(state, expected)) {
    return reply.unauthorized('Sign-in could not be verified.');
  }

  const sessionId = await fastify.buildbase.exchangeCode(code);
  const profile = await fastify.buildbase.profile(sessionId);

  await fastify.knex.transaction(async (trx) => {
    const user = await usersRepository.upsertFromBuildBase(profile, trx);
    const roles = await usersRepository.findUserRolesByEmail(user.email, trx);
    request.session.user = {
      id: user.id,
      email: user.email,
      username: user.username,
      roles: roles.map((role) => role.name),
    };
  });

  request.session.buildbaseSessionId = sessionId;
  await request.session.save();
  return reply.redirect('/api');
});

sameState compares with timingSafeEqual and fails when either side is missing. The state is cleared before the check, so a callback URL works once.

Look at what request.session.user holds: { id, email, username, roles }. The demo's old password login put exactly that there. So its isModerator and isAdmin checks, and every route behind them, work unchanged.

upsertFromBuildBase finds the user by buildbase_id, adopts an existing user with the same email, or creates one with the basic role. One migration adds the column and drops the password:

ALTER TABLE users
  ADD COLUMN buildbase_id VARCHAR(64) NULL UNIQUE AFTER username,
  DROP COLUMN password;

Open /api/auth/buildbase/sign-in in a browser, sign in on the hosted page (whichever of the eight sign-in methods your org has on), and you land on /api greeted by name. The route-level auth hook lets /api/auth/buildbase/* through and still refuses everything else without a session.

3. The payoff: tests with a fake BuildBase

// test/helper.ts
export function fakeBuildBase() {
  const credits = new Map<string, number>();
  const spent = new Set<string>();
  const balance = (email: string) => credits.get(email) ?? 100;
  const emailOf = (sessionId: string) => sessionId.replace(/^session:/, '');

  return {
    credits,
    signInUrl: async (state: string) =>
      `https://auth.buildbase.test/sign-in?state=${state}`,
    exchangeCode: async (code: string) => `session:${code}`, // the code is the email
    profile: async (sessionId: string) => {
      const email = emailOf(sessionId);
      return { id: `bb_${email}`, email, name: email.split('@')[0] };
    },
    spend: async (
      sessionId: string,
      amount: number,
      _d: string,
      key: string
    ) => {
      const email = emailOf(sessionId);
      if (spent.has(key)) return { balanceAfter: balance(email) };
      if (balance(email) < amount) {
        throw Object.assign(new Error('Insufficient credits'), {
          code: 'INSUFFICIENT_CREDITS',
        });
      }
      spent.add(key);
      credits.set(email, balance(email) - amount);
      return { balanceAfter: balance(email) };
    },
    revoke: async () => {},
  };
}

export function config() {
  return { skipOverride: true, buildbase: fakeBuildBase() };
}

The helper's login(email) then drives the real routes: it calls /api/auth/buildbase/sign-in, reads the state from the redirect, and calls the callback with the email as the code. So the state check, the upsert, the role lookup and the session all run in every test that signs in. Only the network is gone.

The example's fake also backs the account screen. Its tests go further than the happy path: a forged state, a callback with no sign-in in progress, a seeded user adopted by email, a first-time user getting basic, and logout revoking the BuildBase session.

Key takeaway

In Fastify, BuildBase is a decorator. Routes call fastify.buildbase, tests swap in a fake through the plugin options, and nothing that reads request.session.user knows sign-in moved.

4. A route that costs a credit

// src/routes/api/tasks/index.ts
async function (request, reply) {
  const key = request.headers['idempotency-key'];
  try {
    await fastify.buildbase.spend(
      request.session.buildbaseSessionId ?? '',
      1,
      'Task created',
      typeof key === 'string' ? key : randomUUID(),
    );
  } catch (err) {
    if ((err as { code?: string }).code === 'INSUFFICIENT_CREDITS') {
      reply.code(402);
      return { message: 'Out of credits.' };
    }
    throw err;
  }
  // ...then create the task, as before
}

The credit is spent before the insert, so an empty workspace gets a 402 and no task is written. The same Idempotency-Key twice is charged once. In the recorded demo, a new user's workspace starts with 5 credits from a Workspace Created workflow: five POST /api/tasks answer 201 and the sixth answers 402. Add the 402 to the route's response schema so Swagger shows it.

For the pricing decisions behind this, see charging per API call and a credits wallet, written for Next.js but identical from the SDK call down.

Where the alternatives are less work

Auth0's Fastify SDK registers /auth/login, /auth/logout and /auth/callback for you and keeps the session in an encrypted cookie. Here you write those routes, about 110 lines with the schemas. The demo's sessions also stay in memory, as upstream's did, so production needs a session store.

@fastify/auth composes several checks with and/or logic, such as "a session or an API key". This example has one session check. If you need both, keep @fastify/auth for the composition and let BuildBase be one of the strategies it calls.

What breaks, and how you can tell

Sign-in answers 503. The BuildBase variables are not set. The route answers with the variables it needs; the rest of the API still runs.

Signing in trips the rate limiter. The demo's global rate limit counts every request, and a sign-in is two, plus Swagger's own assets if you use the docs. Upstream's .env.example set 4 a minute for its tests; ours sets 8. Raise it for anything but the test suite.

The callback answers 500, not 401. The code exchange or the profile read failed (a reused code, or a network error), and the demo's error handler, upstream's, hides the message of a 5xx. Check the server log.

Everyone is signed out after a deploy. Sessions are in memory. Give @fastify/session a store.

The hosted page refuses to send you back. The callback URL must match one 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/fastify-demo my-app.

npm i @buildbase/sdk
fastify
node
auth

Frequently Asked Questions

Do I need @fastify/auth or @fastify/passport with BuildBase?

No. BuildBase does the sign-in and @fastify/session holds the result. @fastify/auth composes checks you already have, such as "session or API key", and ships no strategy of its own, so it can sit on top if you need that composition.

How do I test Fastify routes without calling the auth provider?

Put every BuildBase call behind a decorator, and let the plugin take a replacement through its options. The test helper passes a fake when it builds the app, and the suite drives the real sign-in routes against it: 57 tests on MySQL, with no BuildBase org and no network.

What happens to my existing users table?

One migration adds a unique buildbase_id column and drops the password column. On first sign-in, an existing user with the same email is adopted, so roles and data stay where they are.

How do I charge per API call in Fastify?

Spend a credit at the top of the route handler with an Idempotency-Key, before any work is done. An empty workspace makes the SDK throw INSUFFICIENT_CREDITS, which the route answers with a 402 and writes nothing.

Ship it

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

7-day free trialNo credit card requiredCancel anytime