Authentication
Authentication answers "who is making this request?" In Beignet, the recommended shape is:
- Auth provider or app adapter installs an auth port.
- Hooks enforce route-level authentication at the HTTP boundary.
- The session and request actor are added to context.
- Use cases call
requireUser(ctx)from@beignet/core/portswhen 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:
| Hook | Behavior |
|---|---|
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:
| Helper | Returns | Default error |
|---|---|---|
requireSession(ctx) | The full ctx.auth session | AuthUnauthorizedError (framework-owned 401) |
requireUser(ctx) | The session user, inferred from the app's ctx.auth type | AuthUnauthorizedError (framework-owned 401) |
requireUserId(ctx) | The user's id string | AuthUnauthorizedError (framework-owned 401) |
requireTenant(ctx) | The ctx.tenant activity tenant | TenantRequiredError (framework-owned 403) |
requireTenantId(ctx) | The tenant's id string | TenantRequiredError (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.11The 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.
Read next
- Hooks for hook lifecycle details.
- Authorization for policies and ownership checks.
- Providers for provider lifecycle and setup order.