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 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)
requireTenantScope(ctx)A branded TenantScope for repository boundariesTenantRequiredError (framework-owned 403)
import { requireUser } from "@beignet/core/ports";
import { requireTenantScope } from "@beignet/core/tenancy";

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

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

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.20

The provider supports Better Auth >=1.3.26 <1.7.0. The starter pins Better Auth to 1.6.20 to avoid transitive adapter drift into untested prereleases.

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.

In a Next.js 16 app, you can add a root proxy.ts for faster redirects when a request does not have a Better Auth session cookie. Treat this as an app-specific UX gate only: the static matcher should reflect your protected URL shape, and the Beignet layout/session checks, route hooks, and use-case helpers remain the real authorization boundary.

import { getSessionCookie } from "better-auth/cookies";
import { NextResponse, type NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  const sessionCookie = getSessionCookie(request);

  if (!sessionCookie) {
    return NextResponse.redirect(new URL("/sign-in", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*", "/settings/:path*"],
};

Do not use this cookie-only check as proof of authentication. A request with a stale or manually created cookie must still be rejected by auth.api.getSession, ctx.ports.auth, route hooks, policies, or requireUser(ctx) before protected data or actions run. Route groups such as app/(app) are not part of the URL, so keep the matcher aligned with your app's real paths.

Reaching app ports from auth callbacks

Better Auth callbacks — sendResetPassword, sendVerificationEmail, organization invitation emails — run inside Better Auth's own routes, outside the Beignet request pipeline and before any app context exists. Do not build a parallel mail client or fall back to console.* there: reach the app's ports through the booted server with a dynamic import.

// lib/better-auth.ts
export const auth = betterAuth({
  // ...
  emailAndPassword: {
    enabled: true,
    async sendResetPassword({ user, url }) {
      // The dynamic import breaks the module cycle
      // (server -> providers -> auth). getServer() is memoized, so after
      // first boot this resolves to a cached instance; a callback that
      // fires before any Beignet route in a fresh process pays one boot.
      const { getServer } = await import("@/server");
      const { ports } = await getServer();

      await ports.mailer.send({
        to: user.email,
        subject: "Reset your password",
        text: `Reset your password: ${url}`,
      });
      ports.logger.info("Password reset email sent", { userId: user.id });
    },
  },
});

Going through ports.mailer keeps auth email on the same provider as the rest of the app — instrumented, visible in devtools, and swappable per environment — instead of a second untracked client. The same pattern reaches any port from code that runs outside a request.

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, createTenant } from "@beignet/core/ports";
import { createAuthHooks } from "@beignet/core/server";
import { z } from "zod";
import type { AppContext } from "@/app-context";

const serviceHeadersSchema = z.object({
  authorization: z
    .string()
    .regex(/^Bearer\s+\S+$/i, "Expected Authorization: Bearer <api-key>"),
});

export const serviceAuth = createAuthHooks<AppContext>()({
  name: "internal.service",
  headers: serviceHeadersSchema,
  resolve: async ({ ctx, headers }) => {
    const apiKey = headers.authorization.replace(/^Bearer\s+/i, "");
    const principal = await ctx.ports.apiKeys.verify({ apiKey });

    if (!principal) return null;

    return {
      actor: createServiceActor(principal.id, {
        displayName: principal.displayName,
        metadata: {
          auth: "api-key",
          scopes: [...principal.scopes],
        },
      }),
      tenant: createTenant(principal.tenantId),
    };
  },
});

Keep the verifier behind an app port such as ctx.ports.apiKeys rather than reading environment variables in the hook. That makes route tests independent from module-load env snapshots and lets production adapters verify hashed API keys, tenant membership, rotation status, and scopes without changing route wiring. See Tenancy for resolving tenant scope from verified API keys, sessions, and organization membership.

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.