Back to guides

Own Auth with Fastify


Mount createOwnAuthHandler at /api/auth. The adapter passes the raw request body, Fastify's resolved client IP, and the user agent to Own Auth for request limits, validation, CSRF checks, MFA, and session cookies.

Install Own Auth

Terminal
npm install own-auth fastify @fastify/cookie

Set the database connection, then apply the Own Auth schema.

.env
DATABASE_URL=postgres://user:password@localhost:5432/myapp
Terminal
npx own-auth migrate

Create the auth instance

Add the token pepper used by sessions and one-time auth tokens.

.env
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secret
src/auth.ts
import { createOwnAuth } from "own-auth";

const tokenPepper = process.env.OWN_AUTH_TOKEN_PEPPER!;

export const auth = createOwnAuth({
  tokenPepper,
});

Mount the auth handler

Keep the auth routes in an encapsulated plugin. Its content-type parser preserves the raw body and rejects requests over 64 KB before the handler runs.

src/server.ts
import Fastify, { type FastifyRequest } from "fastify";
import { createOwnAuthHandler } from "own-auth/http";

import { accountRoutes } from "./account";
import { auth } from "./auth";

const maxAuthBodyBytes = 64 * 1024;
const app = Fastify({ logger: true });
const requestContexts = new WeakMap<
  Request,
  { ipAddress?: string; userAgent?: string }
>();
const authHandler = createOwnAuthHandler(auth, {
  maxRequestBodyBytes: maxAuthBodyBytes,
  getRequestContext: (request) => requestContexts.get(request) ?? {},
});

app.register(
  (routes, _options, done) => {
    routes.removeAllContentTypeParsers();
    routes.addContentTypeParser(
      "*",
      { parseAs: "buffer", bodyLimit: maxAuthBodyBytes },
      (_request, body, parseDone) => parseDone(null, body),
    );

    routes.setErrorHandler((error, _request, reply) => {
      if (error.code === "FST_ERR_CTP_BODY_TOO_LARGE") {
        return reply.code(413).send({
          error: { code: "invalid_request", message: "Request body is too large" },
        });
      }
      if (error.code === "FST_ERR_CTP_INVALID_CONTENT_LENGTH") {
        return reply.code(400).send({
          error: { code: "invalid_request", message: "Invalid Content-Length" },
        });
      }
      throw error;
    });

    routes.all("/*", { bodyLimit: maxAuthBodyBytes }, async (request, reply) => {
      const webRequest = toWebRequest(request);
      requestContexts.set(webRequest, {
        ipAddress: request.ip,
        userAgent: request.headers["user-agent"],
      });
      return reply.send(await authHandler(webRequest));
    });

    done();
  },
  { prefix: "/api/auth" },
);

await app.register(accountRoutes);
await app.listen({ host: "0.0.0.0", port: 3000 });

function toWebRequest(request: FastifyRequest): Request {
  if (!request.host) throw new Error("Host header is required");

  const method = request.method.toUpperCase();
  const init: RequestInit & { duplex?: "half" } = {
    method,
    headers: toWebHeaders(request),
  };

  if (method !== "GET" && method !== "HEAD" && Buffer.isBuffer(request.body)) {
    init.body = request.body;
    init.duplex = "half";
  }

  return new Request(
    new URL(request.raw.url ?? "/", `${request.protocol}://${request.host}`),
    init,
  );
}

function toWebHeaders(request: FastifyRequest): Headers {
  const headers = new Headers();
  for (const [name, value] of Object.entries(request.headers)) {
    if (Array.isArray(value)) {
      for (const entry of value) headers.append(name, entry);
    } else if (value !== undefined) {
      headers.set(name, value);
    }
  }
  return headers;
}

The mounted handler exposes the complete Own Auth HTTP API under /api/auth. Configure Fastify trustProxy only for proxies you operate so request.ip, request.host, and request.protocol reflect the external request.

Protect application routes

src/account.ts
import cookie from "@fastify/cookie";
import type { FastifyPluginAsync } from "fastify";
import type { CurrentSession } from "own-auth";
import { defaultSessionCookieName } from "own-auth/http";

import { auth } from "./auth";

declare module "fastify" {
  interface FastifyRequest {
    currentAuth: CurrentSession | null;
  }
}

export const accountRoutes: FastifyPluginAsync = async (app) => {
  await app.register(cookie);
  app.decorateRequest("currentAuth", null);

  app.get("/account", {
    preHandler: async (request, reply) => {
      const token = request.cookies[defaultSessionCookieName];
      const current = token ? await auth.getCurrentSession(token) : null;

      if (!current) {
        return reply.code(401).send({ error: "Unauthorized" });
      }

      request.currentAuth = current;
    },
  }, async (request) => {
    const { id, email, name, imageUrl } = request.currentAuth!.user;
    return { user: { id, email, name, imageUrl } };
  });
};

The hook authenticates the request once. The response exposes only id, email, name, and imageUrl; use request.currentAuth.user.id for ownership and permission checks.

Add the browser client

src/auth-client.ts
import { createOwnAuthClient } from "own-auth/client";

export const authClient = createOwnAuthClient();