Plugins

Plugins are a key part of Faire Auth, they let you extend the base functionalities. You can use them to add new authentication methods, features, or customize behaviors.

Faire Auth comes with many built-in plugins ready to use. Check the plugins section for details. You can also create your own plugins.

Using a Plugin

Plugins can be a server-side plugin, a client-side plugin, or both.

To add a plugin on the server, include it in the plugins array in your auth configuration:

server.ts
import { faireAuth, defineOptions } from "faire-auth";
import { organization, twoFactor } from "faire-auth/plugins";

const cfg = defineOptions({
    baseURL: "http://localhost:3000",
    plugins: [organization(), twoFactor()],
})

export const auth = faireAuth(cfg)

Client plugins are added when creating the client. Most plugins require both server and client plugins to work correctly. You must pass the inferred App type so the client knows about plugin routes:

auth-client.ts
import { createAuthClient } from "faire-auth/client";
import type { App } from "./server"; 

const authClient = createAuthClient<typeof App>()({ 
    plugins: [
        // Add your client plugins here
    ]
});

The client uses a curried generic: createAuthClient<App>()(options). The first call passes the server app type for inference, the second provides client options.

We recommend keeping the auth-client and your normal auth instance in separate files.

server.ts
auth-client.ts

Creating a Plugin

To get started, you'll need a server plugin. Server plugins are the backbone of all plugins, and client plugins are there to provide an interface with frontend APIs to easily work with your server plugins.

If your server plugin has routes that need to be called from the client, you'll also need to create a client plugin.

What can a plugin do?

  • Create custom routes to perform any action you want.
  • Extend database tables with custom schemas.
  • Use middleware to add per-route typed middleware.
  • Use routeHooks to add per-route post-validation hooks.
  • Use hooks to target a specific route or request with before/after matchers.
  • Use onRequest or onResponse if you want to do something that affects all requests or responses.
  • Create custom rateLimit rules.

Create a Server Plugin

To create a server plugin you need to pass an object that satisfies the FaireAuthPlugin interface.

The only required property is id, which is a unique identifier for the plugin. Both server and client plugins can use the same id.

plugin.ts
import type { FaireAuthPlugin } from "faire-auth";

export const myPlugin = () => {
    return {
        id: "my-plugin",
    } satisfies FaireAuthPlugin
}

You don't have to make the plugin a function, but it's recommended to do so. This way you can pass options to the plugin and it's consistent with the built-in plugins.

Routes

To add routes to the server, pass a routes object where each key is a name and each value is an AuthEndpoint created with createEndpoint.

plugin.ts
import type { FaireAuthPlugin } from "faire-auth";
import { createEndpoint, createRoute } from "faire-auth/plugins";
import { res } from "@faire-auth/core/factory";
import * as z from "zod";

export const myPlugin = () => {
    return {
        id: "my-plugin",
        routes: {
            getHelloWorld: createEndpoint(
                createRoute({
                    operationId: "getHelloWorld",
                    method: "get",
                    path: "/my-plugin/hello-world",
                    responses: res(z.object({
                        message: z.string(),
                    })).bld(),
                }),
                (options) => async (ctx) => {
                    return ctx.json({ message: "Hello World" }, 200);
                },
            ),
        },
    } satisfies FaireAuthPlugin
}

createEndpoint takes three arguments:

  1. Route config — created with createRoute(), defines the path, method, operationId, and response schema.
  2. Handler factory — a function that receives FaireAuthOptions and returns the route handler.
  3. Hook (optional) — a post-validation hook for this specific route.

The handler receives a Hono context with auth-specific variables accessible via ctx.get():

  • ctx.get("options"): The resolved Faire Auth options.
  • ctx.get("tables"): Core table definitions.
  • ctx.get("baseURL"): The base URL including the auth path.
  • ctx.get("session"): Session configuration with updateAge and expiresIn.
  • ctx.get("secret"): The secret key.
  • ctx.get("authCookies"): Cookie configuration for core auth cookies.
  • ctx.get("logger"): The logger instance.
  • ctx.get("adapter"): ORM-like functions to interact with the database (findOne, findMany, create, delete, update, updateMany).
  • ctx.get("internalAdapter"): Internal db calls used by Faire Auth (e.g. createSession, createUser).
  • ctx.get("createAuthCookie"): Helper to get cookie name and options for setting/getting cookies.

Rules for Routes

  • Use kebab-case for the route path.
  • Use only POST or GET methods. Functions that modify data should use POST, functions that fetch data should use GET.
  • Use createEndpoint and createRoute to create routes.
  • Use unique paths prefixed with your plugin name to avoid conflicts (e.g. /my-plugin/hello-world instead of /hello-world).

Schema

You can define a database schema for your plugin by passing a schema object. The schema object should have the table name as the key and the schema definition as the value.

plugin.ts
import type { FaireAuthPlugin } from "faire-auth";

const myPlugin = () => {
    return {
        id: "my-plugin",
        schema: {
            myTable: {
                fields: {
                    name: {
                        type: "string"
                    }
                },
                modelName: "myTable" // optional if you want to use a different name than the key
            }
        }
    } satisfies FaireAuthPlugin
}

Fields

By default Faire Auth will create an id field for each table. You can add additional fields to the table by adding them to the fields object.

The key is the column name and the value is the column definition. The column definition can have the following properties:

type: The type of the field. It can be string, number, boolean, date.

required: if the field should be required on a new record. (default: false)

unique: if the field should be unique. (default: false)

reference: if the field is a reference to another table. (default: null) It takes an object with the following properties:

  • model: The table name to reference.
  • field: The field name to reference.
  • onDelete: The action to take when the referenced record is deleted. (default: null)

Other Schema Properties

disableMigration: if the table should not be migrated. (default: false)

plugin.ts
const myPlugin = (opts: PluginOptions) => {
    return {
        id: "my-plugin",
        schema: {
            rateLimit: {
                fields: {
                    key: {
                        type: "string",
                    },
                },
                disableMigration: opts.storage.provider !== "database", 
            },
        },
    } satisfies FaireAuthPlugin
}

If you add additional fields to a user or session table, the types will be inferred automatically on getSession and signUpEmail calls.

plugin.ts
const myPlugin = () => {
    return {
        id: "my-plugin",
        schema: {
            user: {
                fields: {
                    age: {
                        type: "number",
                    },
                },
            },
        },
    } satisfies FaireAuthPlugin
}

This will add an age field to the user table and all user returning endpoints will include the age field and it'll be inferred properly by TypeScript.

Don't store sensitive information in user or session table. Create a new table if you need to store sensitive information.

Route Hooks & Middleware

Plugins can declare per-route hooks and middleware that are merged with the top-level routeHooks and middleware options:

plugin.ts
import type { FaireAuthPlugin } from "faire-auth";
import { createMiddleware } from "faire-auth/plugins";

const myPlugin = () => {
    return {
        id: "my-plugin",
        routeHooks: {
            signUpEmail: (result, ctx) => {
                // runs after validation, before handler
                if (result.success) {
                    console.log("New signup:", result.data.body.email);
                }
            },
        },
        middleware: {
            getSession: createMiddleware()(async (ctx, next) => {
                // runs before the getSession handler
                await next();
            }),
        },
    } satisfies FaireAuthPlugin
}

Global Hooks

For hooks that need to match requests based on custom logic (not just operationId), use the hooks object with before and after arrays. Each entry has a matcher and a handler:

plugin.ts
import { createHook } from "faire-auth/plugins";

const myPlugin = () => {
    return {
        id: "my-plugin",
        hooks: {
            before: [{
                matcher: (context) => {
                    return context.headers.get("x-my-header") === "my-value"
                },
                handler: createHook()(async (ctx) => {
                    // do something before the request
                })
            }],
            after: [{
                matcher: (context) => {
                    return context.path === "/sign-up/email"
                },
                handler: createHook()(async (ctx) => {
                    // do something after the request
                })
            }]
        }
    } satisfies FaireAuthPlugin
}

On Request & On Response

Additional to hooks, you can also hook into right before a request is made and right after a response is returned. This is mostly useful if you want to do something that affects all requests or responses.

On Request

The onRequest function is called right before the request is made. It takes two parameters: the request and the context object.

Here's how it works:

  • Continue as Normal: If you don't return anything, the request will proceed as usual.
  • Interrupt the Request: To stop the request and send a response, return an object with a response property that contains a Response object.
  • Modify the Request: You can also return a modified request object to change the request before it's sent.
plugin.ts
const myPlugin = () => {
    return  {
        id: "my-plugin",
        onRequest: async (request, context) => {
            //do something
        },
    } satisfies FaireAuthPlugin
}

On Response

The onResponse function is executed immediately after a response is returned. It takes two parameters: the response and the context object.

Here's how to use it:

  • Modify the Response: You can return a modified response object to change the response before it is sent to the client.
  • Continue Normally: If you don't return anything, the response will be sent as is.
plugin.ts
const myPlugin = () => {
    return {
        id: "my-plugin",
        onResponse: async (response, context) => {
            //do something
        },
    } satisfies FaireAuthPlugin
}

Rate Limit

You can define custom rate limit rules for your plugin by passing a rateLimit array.

plugin.ts
const myPlugin = () => {
    return {
        id: "my-plugin",
        rateLimit: [
            {
                pathMatcher: (path) => {
                    return path === "/my-plugin/hello-world"
                },
                limit: 10,
                window: 60,
            }
        ]
    } satisfies FaireAuthPlugin
}

Server-plugin Helper Functions

sessionMiddleware

A middleware that checks if the client has a valid session. Import from faire-auth/middleware:

plugin.ts
import { createEndpoint, createRoute } from "faire-auth/plugins";
import { sessionMiddleware } from "faire-auth/middleware";
import { res } from "@faire-auth/core/factory";
import * as z from "zod";

const myPlugin = () => {
    return {
        id: "my-plugin",
        routes: {
            getHelloWorld: createEndpoint(
                createRoute({
                    operationId: "getHelloWorld",
                    method: "get",
                    path: "/my-plugin/hello-world",
                    responses: res(z.object({
                        message: z.string(),
                    })).bld(),
                }),
                (options) => async (ctx) => {
                    const session = ctx.get("session");
                    return ctx.json({ message: "Hello World" }, 200);
                },
            ),
        },
    } satisfies FaireAuthPlugin
}

Creating a Client Plugin

If your plugin adds routes that need to be called from the client, you have two options:

  1. Pass the App type to createAuthClient (recommended) — plugin routes are automatically inferred from the server app type. No client plugin needed for route inference.
  2. Create a client plugin — only needed if your plugin adds client-side logic like custom actions, atoms, or path method overrides.

Plugin routes are automatically available on the client when you pass the App type:

auth.ts
import { faireAuth, defineOptions } from "faire-auth";
import { myPlugin } from "./my-plugin";

const cfg = defineOptions({
    baseURL: "http://localhost:3000",
    plugins: [myPlugin()],
});

const auth = faireAuth(cfg);
export const App = auth.$Infer.App(cfg); 
auth-client.ts
import { createAuthClient } from "faire-auth/client";
import type { App } from "./auth"; 

const authClient = createAuthClient<typeof App>()({}) 

// Plugin routes are automatically typed and available
await authClient.myPlugin.helloWorld.$get();

The client infers the path as an object and converts kebab-case to camelCase. For example, /my-plugin/hello-world becomes myPlugin.helloWorld.

$InferServerPlugin on client plugins is deprecated and no longer works for route inference. Use the App type generic on createAuthClient instead.

Client Plugin for Custom Logic

If your plugin needs client-side actions, atoms, or other non-route functionality, create a client plugin:

client-plugin.ts
import type { FaireAuthClientPlugin } from "faire-auth/client";

export const myPluginClient = () => {
    return {
        id: "my-plugin",
    } satisfies FaireAuthClientPlugin
}

Get Actions

If you need to add additional methods to the client you can use the getActions function. This function is called with the fetch function from the client.

Faire Auth uses Better Fetch to make requests. Better Fetch is a fetch wrapper made by the same team behind Better Auth and is designed to work seamlessly with Faire Auth.

client-plugin.ts
import type { FaireAuthClientPlugin } from "faire-auth/client";
import type { BetterFetchOption } from "@better-fetch/fetch";

const myPluginClient = {
    id: "my-plugin",
    getActions: ($fetch) => {
        return {
            myCustomAction: async (data: {
                foo: string,
            }, fetchOptions?: BetterFetchOption) => {
                const res = $fetch("/custom/action", {
                    method: "POST",
                    body: {
                        foo: data.foo
                    },
                    ...fetchOptions
                })
                return res
            }
        }
    }
} satisfies FaireAuthClientPlugin

As a general guideline, ensure that each function accepts only one argument, with an optional second argument for fetchOptions to allow users to pass additional options to the fetch call. The function should return an object containing data and error keys.

If your use case involves actions beyond API calls, feel free to deviate from this rule.

Get Atoms

This is only useful if you want to provide hooks like useSession.

Get atoms is called with the fetch function and it should return an object with the atoms. The atoms should be created using nanostores. The atoms will be resolved by each framework useStore hook provided by nanostores.

client-plugin.ts
import { atom } from "nanostores";
import type { FaireAuthClientPlugin } from "faire-auth/client";

const myPluginClient = {
    id: "my-plugin",
    getAtoms: ($fetch) => {
        const myAtom = atom<null>()
        return {
            myAtom
        }
    }
} satisfies FaireAuthClientPlugin

See built-in plugins for examples of how to use atoms properly.

Path Methods

By default, inferred paths use GET method if they don't require a body and POST if they do. You can override this by passing a pathMethods object. The key should be the path and the value should be the method ("POST" | "GET").

client-plugin.ts
import type { FaireAuthClientPlugin } from "faire-auth/client";

const myPluginClient = {
    id: "my-plugin",
    pathMethods: {
        "/my-plugin/hello-world": "POST"
    }
} satisfies FaireAuthClientPlugin

Fetch Plugins

If you need to use Better Fetch plugins you can pass them to the fetchPlugins array. You can read more about Better Fetch plugins in the Better Fetch documentation.

Atom Listeners

This is only useful if you want to provide hooks like useSession and you want to listen to atoms and re-evaluate them when they change.

You can see how this is used in the built-in plugins.