Skip to content
Ownfold
Esc
navigateopen⌘Jpreview
On this page

Fastify

Integrate Fastify with the Node bridge, Better Auth, SQLite or PostgreSQL, origin checks, body limits, hooks, ciphertext routes, and shutdown.

@ownfold/fastify bridges Fastify’s raw Node request and response to Ownfold’s standard fetch handler. It calls reply.hijack() because Ownfold writes the response through http.ServerResponse.

The runnable reference is examples/fastify-better-auth-sqlite.

That example is one combination. Use the database and identity choosers to replace SQLite or Better Auth, including a private no-auth single-user process, without changing the Fastify handler.

Install packages

pnpm add @ownfold/better-auth@beta @ownfold/fastify@beta @ownfold/server@beta @ownfold/sqlite@beta better-auth fastify

Use @ownfold/postgres, @ownfold/drizzle, or @ownfold/prisma instead of SQLite when several application instances share coordination state.

Configure the server and adapter

import { betterAuthUserResolver } from "@ownfold/better-auth"
import { SqliteVaultAdapter, applyOwnfoldSqliteSchema } from "@ownfold/sqlite"
import { createVaultServer } from "@ownfold/server"
import { DatabaseSync } from "node:sqlite"
import { auth } from "./auth"

const database = new DatabaseSync("./data/ownfold.sqlite")
database.exec("PRAGMA journal_mode = WAL")
database.exec("PRAGMA foreign_keys = ON")
applyOwnfoldSqliteSchema(database)

export const vaultServer = createVaultServer({
  adapter: new SqliteVaultAdapter(database),
  getUserId: betterAuthUserResolver(auth),
})

Run schema changes as a deployment step in production instead of relying on request-time startup migration. The helper is convenient for local examples and idempotent schema bootstrap.

Mount the Fastify handler

import { createFastifyVaultHandler } from "@ownfold/fastify"
import Fastify from "fastify"
import { vaultServer } from "./vault-server"

const app = Fastify({
  logger: {
    redact: ["req.headers.authorization", "req.headers.cookie", "req.body"],
  },
  bodyLimit: 64 * 1024,
  trustProxy: ["127.0.0.1"],
})

app.all(
  "/api/ownfold/*",
  createFastifyVaultHandler({
    server: vaultServer,
    origin: "https://app.example.com",
    maxBodyBytes: 65_536,
  }),
)
PropType
originNodeVaultOrigin
TypeNodeVaultOrigin
maxBodyBytes?number
Typenumber

origin is the canonical public origin used to reconstruct the Web Request. Pass a fixed HTTPS string whenever possible. A function may select from a validated allowlist for multi-tenant hosts:

origin: ({ request }) => {
  const host = request.headers.host
  if (host === "app.example.com") return "https://app.example.com"
  if (host === "admin.example.com") return "https://admin.example.com"
  throw new Error("Unrecognized public host")
}

Do not echo arbitrary Host, X-Forwarded-Host, or X-Forwarded-Proto values. Fastify’s trustProxy affects its normalized request metadata, but Ownfold’s function receives the raw Node request. Validate the proxy chain and allowlist explicitly.

What reply.hijack() changes

The adapter hijacks the reply before forwarding request.raw and reply.raw. Fastify will not serialize another response afterward. Hooks that need to authenticate, assign request IDs, enforce limits, or set process context must run before the handler. Do not attach logic that expects Fastify’s normal response serialization after a hijacked reply.

Ownfold’s bridge sets its response status, headers, and body on the raw response. Do not call reply.send() in the same handler.

Better Auth

Mount Better Auth at its own /api/auth/* path and pass the real server instance to betterAuthUserResolver(auth). Recovery secrets and application record bodies never enter Better Auth. Password reset restores access to the account session, not to encrypted vault data.

If auth middleware decorates request, remember that Ownfold’s resolver validates the reconstructed Web request through the configured auth API. Keep cookie/header forwarding intact.

Body limits

There are two relevant limits:

  1. Fastify’s bodyLimit protects routes parsed by Fastify.
  2. maxBodyBytes protects the raw Node-to-Web bridge before Ownfold parses protocol JSON.

Ownfold’s fetch layer also rejects protocol bodies above 65,536 bytes. Keep the outer limit at or above that value. Application encrypted-record routes may need a separate, deliberate ciphertext size limit; do not make coordination endpoints unbounded to accommodate files.

SQLite versus PostgreSQL

Use for one Node process and modest coordination traffic. Keep one dedicated connection, enable foreign keys, use WAL where appropriate, back up the database consistently, and avoid nesting adapter calls inside a host transaction on the same synchronous connection.

Use for horizontal scaling, managed failover, or multiple workers. Set a bounded pool, TLS verification, statement/query timeouts, migration ordering, and graceful pool shutdown.

The coordination database never contains application plaintext. Your separate records table stores the versioned encrypted envelope and authorization metadata.

Ciphertext-only record route

import type { FastifyRequest } from "fastify"

const publicOrigin = "https://app.example.com"

const toAuthenticationRequest = (request: FastifyRequest): Request => {
  const headers = new Headers()
  for (const [name, value] of Object.entries(request.headers)) {
    if (value === undefined) continue
    headers.set(name, Array.isArray(value) ? value.join(", ") : String(value))
  }
  return new Request(new URL(request.url, publicOrigin), {
    headers,
    method: request.method,
  })
}

app.put<{ Params: { id: string } }>("/api/notes/:id", async (request, reply) => {
  const webRequest = toAuthenticationRequest(request)
  const validated = await vaultServer.validateEncryptedRecordWrite({
    request: webRequest,
    body: request.body,
    namespace: "notes",
    recordId: request.params.id,
  })

  if (validated.status === "error") {
    return reply.code(mapOwnfoldStatus(validated.error)).send({
      code: validated.error.code,
      message: validated.error.message,
    })
  }

  await notes.upsert({
    userId: await requireUserId(webRequest),
    id: request.params.id,
    encryptedPayload: validated.value.encryptedPayload,
  })

  return reply.code(204).send()
})

If the application route already uses a verified Fastify session decoration, it may obtain the user ID there; envelope validation still needs an authenticated Web Request whose resolver produces the same stable ID.

Hooks and logging

Useful pre-handler controls include request IDs, authentication setup, rate limits, and strict content-type checks. Do not log request.body on Ownfold or encrypted-record routes. Ciphertext is not plaintext, but it is still sensitive user data and can expose sizes, access patterns, and persisted payloads.

Global error handlers should distinguish unexpected framework failures from expected Ownfold protocol responses. Never serialize driver errors, SQL, session cookies, or stack traces to the browser.

Graceful shutdown

Stop accepting new requests, wait for in-flight handlers, then close the database connection/pool. Do not terminate a process halfway through a response. Ownfold’s revisioned operations recover from ambiguous network outcomes, but orderly shutdown reduces retries and operational uncertainty.

for (const signal of ["SIGINT", "SIGTERM"] as const) {
  process.once(signal, async () => {
    await app.close()
    database.close()
  })
}

Production checklist

  • Use a fixed or validated public origin.
  • Configure trustProxy with exact proxy hops or addresses, never broad trust by habit.
  • Redact cookies, authorization headers, bodies, and query parameters where secrets may appear.
  • Apply Ownfold and application migrations before traffic.
  • Use HTTPS and secure cookies.
  • Set separate rate limits for recovery, pairing, and rotation operations.
  • Test shutdown during onboarding and rotation, then verify retry/reconciliation.
  • Confirm backups restore coordination tables consistently with application ciphertext metadata.

Troubleshooting

Fastify reports that a reply was already sent

Do not call reply.send() around createFastifyVaultHandler. The adapter hijacks and completes the raw response itself.

Generated URLs have the wrong scheme or host

Fix the explicit origin or validated origin function. Do not solve it by trusting all forwarded headers.

Requests fail before reaching Ownfold

Compare Fastify’s bodyLimit, content-type parser behavior, hooks, and wildcard route order with the adapter’s maxBodyBytes.

SQLite locks under multiple instances

SQLite is not the appropriate shared coordination backend for that topology. Move to PostgreSQL, Drizzle, or Prisma on a shared transactional database.

Complete example

examples/fastify-better-auth-sqlite includes Better Auth, Ownfold schema bootstrap, encrypted-note routes, origin validation, process controls, and a build-tested client application.

Last updated on August 4, 2026

Was this page helpful?