Shrey Koradia

Feb 26, 2025 • 12 min read

Fastify Multi-Tenant Auth: A Better Auth Integration Guide

Master secure multi-tenant authentication with Fastify and Better Auth in one powerful guide.

Fastify Multi-Tenant Auth: A Better Auth Integration Guide

Hello Dazzling people on the internet.

If you've ever tried to build authentication into your app, you know the struggle—handling sessions, managing tokens, securing user data, and making sure everything scales smoothly. While many solutions exist, they often feel either too restrictive or too complex to integrate.

That’s where Fastify and Better Auth come in. Fastify is known for its blazing-fast performance and developer-friendly API, while Better Auth provides a framework-agnostic way to handle authentication without the usual headaches. Put them together, and you get a lightweight yet better authentication system (puns intended) that doesn't slow you down.

In this article, I’ll walk you through integrating Better Auth with Fastify, ensuring your app gets secure authentication without the unnecessary bloat. Let’s dive in! 🚀

Note: I would love to include more content on Fastify, but for now we will not focus on setting up an ideal Fastify project. If you want to try Fastify, you can start exploring it or use a ready-made template to launch your project. I will add a GitHub link to a Fastify template in the resources section below.

Step - 1 Go to https://www.better-auth.com/docs/introduction and let us start diving into the basic usage part, we will try to add complications later on

Caveat:
Before we actually start, we need to create environmental variables. Auth Secret acts as a key for generating hash or encryption likewise.

BETTER_AUTH_SECRET=
BETTER_AUTH_URL= http://localhost:5000 #Base URL of your app

Step -2 Let's start building auth.ts as mentioned, somewhere in the root directory or inside of src (if you have typescript config having a strict checks it will not allow any .ts files to be compiled out of src directory hence as a suggestion start by src/libs/auth.ts

Caveat:
Heading heavy towards backend we will try to figure out something more native kind of approach that is we don't use better-auth/client on the client side hence we will just curate the api's on the backend for whatever we need to show the user let's say for example we need to show the session details likewise

Step - 3 Now adding the part inside of auth.ts where we can actually see how it rolls with the schema and other part, if you have used authentication mechanism provided by Supabase or Firebase you might have noticed they keep a database for the users, but in case of better-auth it stores in the same database where we tend to store the other application data.

hence, let start rolling ahead with auth.ts which gives a better idea .

import { PrismaClient } from "@prisma/client";
import { betterAuth } from "better-auth";
import { bearer, jwt } from "better-auth/plugins";
import { prismaAdapter } from "better-auth/adapters/prisma";

const prisma = new PrismaClient();
export const auth = betterAuth({
  secret: process.env.BETTER_AUTH_SECRET,
  baseURL: process.env.BETTER_AUTH_URL,
  database: prismaAdapter(prisma, {
    provider: "postgresql",
  }),
  emailAndPassword: {
    enabled: true,
    // autoSignIn: false,
    // maxPasswordLength: 20,
    // minPasswordLength: 8,
  },
  session: {
    expiresIn: 60 * 60 * 24 * 7,
    updateAge: 60 * 60 * 24,
  },
});

in the above section we can see I have used the part where we have tried using postgresql as our database and with the help of prisma adapater it can translate to prisma client ( dont't know what Adapter is , than here you go , Adapters act as translators between Prisma Client and the JavaScript database driver)

We started figuring out credential based login with emailAndPassword setting it to true and we can have couple of other options as a choice to autoSignIn which are maxPasswordLength, minPasswordLength acting as a payload validation in the case of credential based methods.

The third parameter as of now is session which says in how much time does the session Expires with the expiresIn and the updateAge is the number of days after which the expiration of the session is updated according to our block of code it says every 1 days the session expiration is updated.

Now there is a term that I have used in the title of this article, Multi Tenant
SAAS authorization, so how are we going to implement this feature? as you know SAAS application have data's of multiple organization or workspaces and to be honest we should never leak the data of one workspace to another as it is not ideal for our users to see some important data from one's workspace to another. So there are more custom solutions to it let us discuss the custom solution one by one


Stateless approach (JWT based) :

Signup Flow

  1. User Signs Up → The moment the user registers (e.g., via email/password or Google login), we generate a JWT token that contains only user details (without workspace details). This ensures the user is authenticated on the platform first.

  2. Workspace Creation → After generating the user’s JWT, we proceed with workspace creation in the background.

  3. JWT Update (Optional) → Once the workspace is created, we can either:

    • Issue a new JWT with user + workspace details, or

    • Let the client fetch workspace details separately using the existing token.

Login Flow

  • If the user logs in via Google or another provider, we first generate a JWT without workspace details to check if they are on the platform.

  • If they are associated with multiple workspaces, we show them a list to pick from.

  • After selection, we issue a new JWT that includes both user + selected workspace details.

Why This Approach?

  • Ensures Authentication First: User is authenticated before workspace creation.

  • Supports Multi-Workspace Users: Users who are part of multiple workspaces can choose where they want to log in.

  • Keeps Process Lean: No dependency on workspace creation before issuing the first JWT.


Ideally we blacklist the token with the help of in memory DB (redis), token of the user details without workspace info for registering and checking if the user is registered on the platform gets instantly blacklisted when we get the new token with the workspace details to ensure no malfunctions can happen ideally.

But to do this much is a bit of pain and hassle right , Hence going to auth library where it supports something like this is a cherry on the top of the cake. Hence Better Auth could help us play a pivot role ( as it have a plugin of organizations)
where we can ideally with just some few lines and running a cli script we can ideally generate prisma schemas and structres and also generate migrations for the approach they term it as organizations plugin you can check the docs as well
https://www.better-auth.com/docs/plugins/organization

Now after bit tweaking the auth settings we can add the Organization plugin inside of the auth settings and it looks something like this

import { PrismaClient } from "@prisma/client";
import { betterAuth } from "better-auth";
import { bearer, jwt, openAPI, organization } from "better-auth/plugins";
import { prismaAdapter } from "better-auth/adapters/prisma";

const prisma = new PrismaClient();
export const auth = betterAuth({
  secret: process.env.BETTER_AUTH_SECRET,
  baseURL: process.env.BETTER_AUTH_URL,
  database: prismaAdapter(prisma, {
    provider: "postgresql",
  }),
  emailAndPassword: {
    enabled: true,
  },
  session: {
    expiresIn: 60 * 60 * 24 * 7,
    updateAge: 60 * 60 * 24,
  },
  plugins: [bearer(), organization(), openAPI()],
});


Here i added bearer plugin, cause i wanted to add the authorization check in the route handlers in fastify (pre-handlers) where i can check the request headers where ideally will add the session token in the authorization of headers from client side, it looks something like below inside the axios interceptors before sending the API request to our fastify server.

Bearer  <your token>

also better auth supports entirely cookie based approach for session but i am showing you the combined approach for adding authentication mechanism for mobile apps which do not have the cookie based approach just like web-apps.

Hence , now after adding the organization as a plugin we can see that , we need to generate the schema and that is the beauty of the better auth it helps curating the necessary schema by itself with just a command (using prisma adapter is recommended by me , it is way to easy ) ...... just run the below command :)

npx @better-auth/cli generate

and it automatically creates the schema for it ( I believe if you are setting up with sql than you may need to run the migrations .... which will reflect in the database)

Also to add organization in the application like we need to do for saas apps than we have the usual methods the most authentication system provides like clerk or auth0,

here is an example of it, like how do we use it

we add this in the fastify routes file , where we serailize the json schema , here i have added zod for type saftey as well as the json schema serialization it does help me manage both of it together.

Caveats from fastify docs :

Fastify uses a schema-based approach, and even if it is not mandatory we recommend using JSON Schema to validate your routes and serialize your outputs. Internally, Fastify compiles the schema into a highly performant function.

Validation will only be attempted if the content type is application-json, as described in the documentation for the content type parser.

const RegisterSchema = z.object({
  full_name: z.string(),
  email: z.string().email(),
  password: z.string().min(6).max(18),
  organization_name: z.string(),
  organization_slug: z.string(),
});

const registerJsonSchema: FastifySchema = {
  body: zodToJsonSchema(RegisterSchema),
  response: {
    200: zodToJsonSchema(SignupResponseSchema),
  },
};

fastify.post(
    "/register",
    { schema: registerJsonSchema },
    authControllerInstance.register
  );

Also in the below code this is a sort of an example to start with, i am showing the methods given by better-auth, hence try tweaking it as you like .
hence in the below code we can see the method signupEmail and createOrganization this is how we can create the user and organization, nothing fancy here just using the methods by better-auth.

  async function register(registerPayload: RegisterPayload) {
   
    const { user, token } = await auth.api.signUpEmail({
      body: {
        name: registerPayload.full_name,
        email: registerPayload.email,
        password: registerPayload.password,
      },
    });

    if (!token) {
      throw new AppError(
        ErrorCode.UNPROCESSABLE_ENTITY,
        "Something went wrong! ",
        422
      );
    }

    if (!user) {
      throw new AppError(
        ErrorCode.UNPROCESSABLE_ENTITY,
        "Something went wrong!",
        422
      );
    }
    const organization = await auth.api.createOrganization({
      body: {
        name: registerPayload.organization_name,
        slug: registerPayload.organization_slug,
        userId: user.id,
      },
    });

    if (!organization) {
      throw new AppError(
        ErrorCode.UNPROCESSABLE_ENTITY,
        "Something went wrong!",
        422
      );
    }

    return { token, organizationId: organization.id };
  }


Now just before we wrap up the blog we might need to check the part which we talked before we began the bearer plugin (authorising the session token or id)

Now in fastify there is a hook called preHandler , ideally which is called before the route handler function

according to docs it is termed something like this - The preHandler hook allows you to specify a function that is executed before a routes's handler. In short is it is a pre-validation.

let say we wanted to created and auth route where we wanted to show the details of the user (active session of the user) than we need to check the session exists and if it does than the session token is valid otherwise it is a expired session or not a valid token.

here is how we can see it in step by step first add prehandler check by assigninig a fastify plugin which checks the session and stores the detail in fastify instance.

fastify.get(
    "/organizations",
    { preHandler: [fastify.authenticate] },
    authControllerInstance.organizationList
  );

By the way I remember a saying it goes like in fastify everything is deeply connected with plugins and hooks .

Hence to implement the plugin you need fastify-plugin to be installed as the package

import { FastifyPluginAsync, FastifyRequest, FastifyReply } from "fastify";
import fp from "fastify-plugin";
import { auth } from "../lib/auth";
import { Session, User } from "better-auth/types";

// Extend Fastify types for custom decorations
declare module "fastify" {
  interface FastifyInstance {
    authenticate: (
      request: FastifyRequest,
      reply: FastifyReply
    ) => Promise<void>;
  }

  interface FastifyRequest {
    session?: Session;
    user?: User;
  }
}

const authPlugin: FastifyPluginAsync = fp(async (fastify) => {
  fastify.decorate(
    "authenticate",
    async (request: FastifyRequest, reply: FastifyReply) => {
      try {
        const authHeader = request.headers.authorization;

        if (!authHeader || !authHeader.startsWith("Bearer ")) {
          return reply.code(401).send({ error: "Unauthorized" });
        }

        const fetchHeaders = new Headers(request.headers as HeadersInit);

        // Verify token using Better Auth
        const sessionData = await auth.api.getSession({
          headers: fetchHeaders,
        });

        if (!sessionData) {
          return reply.code(401).send({ error: "Invalid or expired token" });
        }

        // Attach session details to the request for later use
        request.session = sessionData.session;
        request.user = sessionData.user;
      } catch (err) {
        return reply.code(500).send({ error: "Authentication error" });
      }
    }
  );
});

export default fp(authPlugin);

And hence this is how we can check if the session is active or not , some caveats for the above code, some fancy terms that i have used one of them which is decorate is explained below .

Caveat:

Decorators allow developers to attach information to core objects, like adding the user to an incoming request. Decorators allow for the attachment of properties to the request, without incurring the huge cost of optimizations and megamorphisms.

In essence, decorators ensure every route has its own hidden class for Request and Reply, allowing V8 to optimise your code to the fullest extent.

Hence when i used decorate at that moment i tried to add some meta-data to the plugin which will be called upon the prehandler of the route which tries to test if the session is vaid and if yes than add the user and session details to the fastify request, otherwise throws an error.




This is a long read to be honest and if you have finished at the very end of it. I think I have made some sort of valuable impact with how we can use the better-auth with fastify .

Anyways this was a special drop from my end where i have tried attributing better auth with fastify, I know it didn't have much of fastify but I will try posting some new stuff related to fastify in the upcoming articles.

Until then Share for Good Karma and a bowl of ice cream 🍨

Happy Summer Peers !



Resources:
Fastify Template
Better Auth

Join Shrey on Peerlist!

Join amazing folks like Shrey and thousands of other people in tech.

Create Profile

Join with Shrey’s personal invite link.

2

17

0