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 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) |
requireTenantScope(ctx) | A branded TenantScope for repository boundaries | TenantRequiredError (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.20The 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.
Read next
- Hooks for hook lifecycle details.
- Tenancy for tenant resolution and tenant-aware policies.
- Authorization for policies and ownership checks.
- Providers for provider lifecycle and setup order.