Skip to main content

Overview

The Toots API uses better-auth for session-based authentication. Authentication is handled transparently through middleware, and protected procedures automatically validate the user session.

Authentication setup

Toots uses better-auth with the Prisma adapter for PostgreSQL. The auth configuration is defined in apps/web/lib/auth/auth.ts:5-12:
export const auth = betterAuth({
    database: prismaAdapter(prisma, {
        provider: "postgresql",
    }),
    emailAndPassword: {
        enabled: true,
    },
});
Email and password authentication is currently the only enabled authentication method.

Session context

Every RPC request includes session information in the context. The session is retrieved from request headers and passed to each procedure. From apps/web/app/rpc/[[...rest]]/route.ts:14-23:
async function handleRequest(request: Request) {
  const session = await auth.api.getSession({ headers: request.headers })

  const { response } = await handler.handle(request, {
    prefix: "/rpc",
    context: {
      headers: request.headers,
      session,
    },
  })

  return response ?? new Response("Not found", { status: 404 })
}

Protected procedures

The API provides two base types for defining procedures:

Public procedures

Use base for procedures that don’t require authentication:
import { base } from "@/server/context"

export const listProjects = base
  .input(listProjectsInput)
  .handler(async ({ input }) => {
    // Anyone can list projects
    const projects = await prisma.project.findMany({
      take: input.limit,
      skip: input.cursor,
    })
    return { items: projects }
  })

Protected procedures

Use protectedBase for procedures that require authentication. The middleware automatically validates the session and adds the user to the context. From apps/web/server/context.ts:10-19:
const authMiddleware = base.middleware(async ({ context, next }) => {
  if (!context.session) {
    throw new Error("Unauthorized")
  }
  return next({
    context: { ...context, user: context.session.user },
  })
})

export const protectedBase = base.use(authMiddleware)
Example of a protected procedure from apps/web/server/procedures/projects.ts:34-43:
export const createProject = protectedBase
  .input(createProjectInput)
  .handler(async ({ input, context }) => {
    // context.user is guaranteed to exist
    const project = await prisma.project.create({
      data: {
        name: input.name,
        description: input.description ?? "",
        userId: context.user.id,
      },
    })
    return project
  })
When using protectedBase, the context.user object is guaranteed to exist and contains id, email, and optionally name.

Session structure

The session object includes user information when authenticated:
type Session = {
  user: {
    id: string
    email: string
    name?: string
  }
  session: object
} | null

Authentication flow

  1. User authenticates: Client calls the better-auth API endpoints at /api/auth
  2. Session created: better-auth creates a session and stores it in the database
  3. Session cookie: Client receives a session cookie
  4. RPC calls: All subsequent RPC calls include the session cookie
  5. Session validation: The RPC handler extracts and validates the session
  6. Context injection: Session data is added to the request context
  7. Middleware check: Protected procedures verify the session exists

Making authenticated requests

From the client side, authentication is handled automatically through cookies:
import { rpc } from "@/lib/orpc"

// This call automatically includes the session cookie
const project = await rpc.projects.create({
  name: "My Project",
  description: "Requires authentication"
})

Error handling

If a protected procedure is called without authentication, it throws an “Unauthorized” error:
try {
  const project = await rpc.projects.create({ name: "Test" })
} catch (error) {
  // Error: "Unauthorized"
  console.error(error)
}

Server-side authentication

When making RPC calls from server components, the session is automatically retrieved from Next.js headers. From apps/web/lib/orpc.server.ts:10-17:
;(globalThis as { $client?: RouterClient<typeof router> }).$client =
  createRouterClient(router, {
    context: async () => {
      const h = await headers()
      const session = await auth.api.getSession({ headers: h })
      return { headers: h, session }
    },
  })
This ensures that server-side rendering and server actions have access to the current user session.

Build docs developers (and LLMs) love