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) 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:

MetadataBehavior
omitted or "public"Do not resolve a session
"optional"Resolve a session when present, but do not fail
"required" or trueResolve 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