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)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.
When requireUser(req) throws the shared auth error from a Beignet route
or lifecycle hook, the server runtime returns a framework-owned 401 response
with the standard error envelope.
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 declare authentication requirements as metadata:
export const createPost = posts
.post("/")
.meta({ auth: "required" })
.body(CreatePostInput)
.responses({ 201: PostOutput });
Metadata is not security by itself. It gives hooks a typed, inspectable signal.
HTTP boundary hook
Use createAuthHooks(...) to reject unauthenticated requests before the route
handler runs:
import { createAuthHooks } from "@beignet/core/server";
import { createAnonymousActor, createUserActor } from "@beignet/core/ports";
export const authHooks = createAuthHooks<AppContext>({
assign: ({ ctx, session }) => ({
...ctx,
auth: session,
actor: session
? createUserActor(session.user.id, { displayName: session.user.name })
: createAnonymousActor(),
}),
});
By default, createAuthHooks(...) reads contract.metadata.auth:
| Metadata | Behavior |
|---|---|
omitted or "public" | Do not resolve a session |
"optional" | Resolve a session when present, but do not fail |
"required" or true | Resolve a session and return 401 when missing |
The helper uses ctx.ports.auth.getSession(req) by default. Pass getSession
when your app resolves identity from a different place:
export const authHooks = createAuthHooks<AppContext>({
getSession: ({ req, ctx }) => ctx.ports.auth.getSession(req),
assign: ({ ctx, session }) => ({
...ctx,
auth: session,
actor: session
? createUserActor(session.user.id, { displayName: session.user.name })
: createAnonymousActor(),
}),
});
Hook responses are framework-owned, so your business contract does not need to declare every infrastructure response such as malformed JSON, auth failures, or rate limits.
createContext should define the baseline context shape before hooks run:
export const server = await createNextServer({
ports,
hooks: [authHooks],
createContext: ({ ports }) => ({
actor: createAnonymousActor(),
requestId: crypto.randomUUID(),
auth: null,
ports,
}),
});
If your context includes a request-bound gate, rebind it inside assign(...)
after the session and actor are attached.
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:
export function requireUser(ctx: AppContext): AuthUser {
if (!ctx.auth?.user) {
throw appError("Unauthorized");
}
return ctx.auth.user;
}
export function requireTenant(ctx: AppContext): ActivityTenant {
if (!ctx.tenant) {
throw appError("Forbidden", { message: "Tenant required" });
}
return ctx.tenant;
}
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,
});
});
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
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 the
app's Unauthorized error.
Read next
- Hooks for hook lifecycle details.
- Authorization for policies and ownership checks.
- Providers for provider lifecycle and setup order.