Authentication

Authentication answers "who is making this request?" In Beignet, the recommended shape is:

  1. Auth provider or app adapter installs an auth port.
  2. Hooks enforce route-level authentication at the HTTP boundary.
  3. The session and request actor are added to context.
  4. Use cases call requireUser(ctx) from @beignet/core/ports when a workflow needs a signed-in user.

Authorization is separate. It answers whether that user may perform a specific business action. See Authorization.

Auth port

Beignet apps use the shared AuthPort shape from @beignet/core/ports. Production apps can replace the anonymous adapter with Better Auth or another session system without changing hooks or use cases.

import type { AuthPort, AuthSession } from "@beignet/core/ports";

export type AuthUser = {
  id: string;
  email?: string;
};

export type AppAuthSession = AuthSession<AuthUser>;
export type AppAuthPort = AuthPort<AuthUser>;

Keep this as an app-facing interface. Your use cases and hooks should not need to know whether the user came from Better Auth, JWT, a session cookie, or a test adapter.

Route metadata

Contracts can describe authentication requirements as metadata for OpenAPI, docs, clients, and conventions:

export const createPost = posts
  .post("/")
  .meta({ auth: "required" })
  .body(CreatePostInput)
  .responses({ 201: PostOutput });

Metadata is not security by itself. Runtime enforcement should be visible in route wiring with route hooks.

HTTP boundary hooks

Use createAuthHooks(...) to reject unauthenticated requests before the route handler runs:

import { createAuthHooks, defineRouteGroup } from "@beignet/core/server";
import type { AppContext } from "@/app-context";

export const auth = createAuthHooks<AppContext>()({
  resolve: ({ ctx }) => {
    if (!ctx.auth) return null;

    return { user: ctx.auth.user };
  },
});

The outer call binds the app context; the inner call takes the auth options and infers the added context fields from what resolve returns.

The helper returns explicit route-hook factories:

HookBehavior
auth.public()Mark the route as intentionally public
auth.optional()Resolve auth when present and add optional auth fields to ctx
auth.required()Resolve auth, return a framework-owned 401 when missing, and add authenticated fields to ctx

Attach those hooks in feature route groups:

export const postRoutes = defineRouteGroup<AppContext>()({
  name: "posts",
  hooks: [auth.optional()],
  routes: [
    { contract: listPosts, useCase: listPostsUseCase },
    {
      contract: createPost,
      hooks: [auth.required()],
      useCase: createPostUseCase,
    },
  ],
});

The hook guards the HTTP boundary; the bound use case reads the resolved session from its own context (for example through requireUser(ctx) from @beignet/core/ports). Full handle routes that need hook-added fields typed on ctx can wrap the route in defineRoute<AppContext>(); binder routes (routes registered as { contract, useCase } — see Server) do not need it.

Auth failures are framework-owned, so your business contract does not need to declare every infrastructure response such as malformed JSON, missing auth, or rate limits.

The server/context.ts blueprint should resolve the session once and define the baseline context shape before route hooks run:

// server/context.ts
import { createAnonymousActor, createUserActor } from "@beignet/core/ports";
import { defineServerContext } from "@beignet/core/server";
import type { AppContext } from "@/app-context";

export const appContext = defineServerContext<
  AppContext,
  AppContext["ports"]
>()({
  gate: (ports) => ports.gate,
  request: async ({ ports, req, requestId, trace }) => {
    const auth = await ports.auth.getSession(req);

    return {
      actor: auth ? createUserActor(auth.user.id) : createAnonymousActor(),
      auth,
      requestId,
      ...trace,
      ports,
    };
  },
});

auth is the resolved provider session or null. Route hooks enforce HTTP access and may narrow handler ctx, but they should not be the only place that derives the app's audit actor.

Use-case helpers

Use cases should still require a user when the workflow needs one. That keeps the rule active when the workflow is called from HTTP, jobs, scripts, event handlers, or tests. @beignet/core/ports exports context helpers for this:

HelperReturnsDefault error
requireSession(ctx)The full ctx.auth sessionAuthUnauthorizedError (framework-owned 401)
requireUser(ctx)The session user, inferred from the app's ctx.auth typeAuthUnauthorizedError (framework-owned 401)
requireUserId(ctx)The user's id stringAuthUnauthorizedError (framework-owned 401)
requireTenant(ctx)The ctx.tenant activity tenantTenantRequiredError (framework-owned 403)
requireTenantId(ctx)The tenant's id stringTenantRequiredError (framework-owned 403)
import { requireTenant, requireUser } from "@beignet/core/ports";

const createPost = useCase
  .command("posts.create")
  .input(CreatePostInput)
  .output(PostOutput)
  .run(async ({ ctx, input }) => {
    const user = requireUser(ctx);
    const tenant = requireTenant(ctx);

    return ctx.ports.posts.create({
      ...input,
      tenantId: tenant.id,
      authorId: user.id,
    });
  });

The user type is inferred from the app's ctx.auth session, so user above is the app's own AuthUser shape without casts.

The server maps AuthUnauthorizedError to a framework-owned 401 with code UNAUTHORIZED and TenantRequiredError to a framework-owned 403 with code TENANT_REQUIRED, so contracts do not need to declare these infrastructure responses. Pass options.error when a workflow should throw an app-catalog error instead:

const user = requireUser(ctx, { error: () => appError("Unauthorized") });

App-owned wrappers around these helpers are still fine when they add app semantics, but the core helpers are the default.

Better Auth provider

Use @beignet/provider-auth-better-auth when Better Auth owns session lookup:

bun add @beignet/core @beignet/provider-auth-better-auth better-auth@1.6.11

The starter pins Better Auth to 1.6.11 to avoid transitive adapter drift in clean installs. Revisit the pin intentionally when upgrading Better Auth.

import { createAuthBetterAuthProvider } from "@beignet/provider-auth-better-auth";
import { auth } from "@/lib/better-auth";

export const providers = [
  createAuthBetterAuthProvider(auth),
];

The provider wraps an already configured Better Auth instance and installs the same shared AuthPort on ctx.ports.auth.

Better Auth still owns its own login, signup, callback, and session routes. Mount those routes beside your Beignet API routes.

Devtools

When the devtools provider is installed before the Better Auth provider, auth checks appear in the Auth tab. The provider records getSession, getUser, and requireUser operations with authenticated status and duration. User and session objects are not recorded.

Testing

Tests can pass an auth adapter directly:

import { createStaticAuth } from "@beignet/core/ports";

const auth = createStaticAuth({
  user: {
    id: "user_1",
    email: "user@example.com",
  },
});

const ctx = {
  user: await auth.getUser(new Request("http://test.local")),
  ports: {
    auth,
    posts: createInMemoryPosts(),
  },
};

For unauthenticated tests, return null and assert that the use case throws AuthUnauthorizedError (code UNAUTHORIZED).

Typed credential headers

Service-to-service surfaces such as internal APIs and webhook receivers authenticate with credentials in request headers instead of a user session. Declare a headers schema on the auth hooks for these surfaces. The hook validates the raw lowercase request header record itself, so resolve receives typed header values without casting and without depending on each route's contract header schema:

import { createServiceActor } from "@beignet/core/ports";
import { createAuthHooks } from "@beignet/core/server";
import { z } from "zod";
import type { AppContext } from "@/app-context";
import { env } from "@/lib/env";

const serviceHeadersSchema = z.object({
  "x-api-key": z.string().min(1),
  "x-service-name": z.string().min(1),
});

export const serviceAuth = createAuthHooks<AppContext>()({
  name: "internal.service",
  headers: serviceHeadersSchema,
  resolve: ({ headers }) => {
    if (headers["x-api-key"] !== env.INTERNAL_API_KEY) return null;

    return {
      actor: createServiceActor(headers["x-service-name"]),
    };
  },
});

On required() routes, a header schema failure or a null return from resolve is an authentication failure: a framework-owned 401, not a 422. On optional() routes a schema failure skips auth resolution, and public() never parses headers.

Session-based hooks do not need a headers schema. Without one, resolve still receives the raw lowercase header record.