Beignet is experimental alpha software. The 0.0.x package line is for early
evaluation, and APIs may change between releases while the framework settles.
Better Auth provider for Beignet applications.
The provider wraps an already-configured Better Auth
server instance and exposes the shared AuthPort from @beignet/core/ports on
ctx.ports.auth. Your app still owns Better Auth configuration, database
schema, and auth routes.
What this provider does:
ports.auth with getSession, getUser, and requireUser methodsWhat this provider does NOT do:
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 standard Beignet
starter pins Better Auth to 1.6.20 so clean installs do not float into
untested prerelease adapter versions.
This package ships a TanStack Intent skill for coding agents:
@beignet/provider-auth-better-auth#auth-provider. Load it when wiring
Better Auth configuration, auth routes, AuthPort typing, deferred auth port
registration, server context, createAuthHooks, user/session helpers,
policies, devtools auth instrumentation, tests, or auth doctor drift.
First, set up Better Auth with your database and configuration:
// lib/better-auth.ts
import { betterAuth } from "better-auth";
import { db } from "./db"; // Your Drizzle/Prisma/etc. client
export const auth = betterAuth({
database: db,
emailAndPassword: {
enabled: true,
},
// ...other Better Auth configuration
});
Own the public session shape in your app, then add the auth port to your
application's ports type:
// ports/auth.ts
import type {
AuthPort as BeignetAuthPort,
AuthSession as BeignetAuthSession,
} from "@beignet/core/ports";
export type AuthUser = {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
};
export type AuthSessionMetadata = unknown;
export type AuthPort = BeignetAuthPort<AuthUser, AuthSessionMetadata>;
export type AuthSession = BeignetAuthSession<AuthUser, AuthSessionMetadata>;
// ports/index.ts
import type { ActivityActor, GatePort } from "@beignet/core/ports";
import type { AuthPort } from "./auth";
export type AppAuthorizationContext = {
actor: ActivityActor;
};
export type AppPorts = {
auth: AuthPort;
gate: GatePort<AppAuthorizationContext, []>;
// ...other ports (db, mailer, eventBus, etc.)
};
Register the provider when creating your server:
// server/providers.ts
import { createBetterAuthProvider } from "@beignet/provider-auth-better-auth";
import { auth } from "@/lib/better-auth";
export const providers = [
createBetterAuthProvider({ auth }),
// ...other providers
];
// app-context.ts
import type { ActivityActor, BoundGate } from "@beignet/core/ports";
import type { InferProviderPorts } from "@beignet/core/providers";
import type { TraceContext } from "@beignet/core/tracing";
import type { AppPorts } from "@/ports";
import type { AuthSession } from "@/ports/auth";
import type { providers } from "@/server/providers";
export type AppRuntimePorts = AppPorts & InferProviderPorts<typeof providers>;
export type AppContext = {
actor: ActivityActor;
auth: AuthSession | null;
gate: BoundGate<[]>;
requestId: string;
ports: AppRuntimePorts;
} & Partial<TraceContext>;
// infra/port-wiring.ts
import { createGate, definePorts } from "@beignet/core/ports";
import type { AppPorts } from "@/ports";
const gate = createGate({ policies: [] });
export const initialPorts = definePorts<AppPorts>()({
bound: { gate },
deferred: ["auth"],
});
// server/context.ts
import { createAnonymousActor, createUserActor } from "@beignet/core/ports";
import { defineServerContext } from "@beignet/core/server";
import type { AppContext, AppRuntimePorts } from "@/app-context";
export const appContext = defineServerContext<AppContext, AppRuntimePorts>()({
gate: (ports) => ports.gate,
request: async ({ req, ports, requestId, trace }) => {
const auth = await ports.auth.getSession(req);
return {
actor: auth ? createUserActor(auth.user.id) : createAnonymousActor(),
auth,
requestId,
...trace,
ports,
};
},
});
// server/index.ts
import { createNextServer, createNextServerLoader } from "@beignet/next";
import { initialPorts } from "@/infra/port-wiring";
import { appContext } from "@/server/context";
import { routes } from "@/server/routes";
export const getServer = createNextServerLoader(async () => {
const { providers } = await import("./providers");
return createNextServer({
ports: initialPorts,
providers,
context: appContext,
routes,
});
});
Use createAuthHooks(...) to create route-scoped auth hooks:
// server/auth-hooks.ts
import { createAuthHooks } from "@beignet/core/server";
export const auth = createAuthHooks<AppContext>()({
resolve: ({ ctx }) => {
return ctx.auth ? { user: ctx.auth.user } : null;
},
});
Then attach the hooks where routes are wired:
import { defineRouteGroup } from "@/lib/routes";
export const accountRoutes = defineRouteGroup({
name: "account",
hooks: [auth.required()],
routes: [
{
contract: getProfile,
handle: async ({ ctx }) => getProfileUseCase.run({ ctx }),
},
],
});
You can also check authentication in use cases:
// features/users/use-cases/get-profile.ts
import { createUseCase } from "@beignet/core/application";
import { z } from "zod";
import { requireUser } from "@/lib/auth";
const UserProfileSchema = z.object({
id: z.string(),
email: z.string().email(),
});
const useCase = createUseCase<AppContext>();
export const getUserProfile = useCase
.query("users.profile")
.input(z.object({ userId: z.string() }))
.output(UserProfileSchema)
.run(async ({ ctx, input }) => {
requireUser(ctx);
return ctx.ports.db.users.getProfile(input.userId);
});
In the standard app shape, the server's context.request factory reads the
request once with
ctx.ports.auth.getSession(req) and stores the result on ctx.auth. Use cases
then call an app-owned helper such as requireUser(ctx) instead of depending
on the raw request.
beignet doctor --strict checks that installed Better Auth providers are
registered in server/providers.ts. The provider does not declare Beignet-owned
env vars because Better Auth configuration, secrets, database adapter options,
and social provider credentials stay in your app's Better Auth setup.
The provider contributes ctx.ports.auth, the standard Beignet AuthPort.
It does not expose a Better Auth escape hatch; route handlers that need
provider-native auth flows should mount Better Auth's own routes separately,
usually under app/api/auth/[...all]/route.ts.
Better Auth callbacks such as sendResetPassword and organization
invitation emails run outside the Beignet request pipeline. Reach the app's
mail and logger ports through the booted server instead of constructing a
parallel mail client:
async sendResetPassword({ user, url }) {
// Dynamic import breaks the module cycle (server -> providers -> auth);
// getServer() is memoized, so this resolves to a cached instance.
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}`,
});
}
This keeps auth email on the same instrumented, environment-swappable provider as the rest of the app. See the Authentication docs for the full pattern.
When @beignet/devtools is installed before this provider, auth checks
appear under the dashboard's Auth watcher.
The provider records auth.getSession, auth.getUser, and
auth.requireUser events with the operation, authenticated status, and
duration. User and session objects are not recorded. Provider failures are
recorded with .failed event names and the original error is rethrown.
Unauthenticated requireUser(...) calls throw AuthUnauthorizedError, which
the Beignet server maps to a framework-owned 401 response. Better Auth
session lookup or route errors are rethrown so hooks and server error handling
can report them consistently.
Use an app-owned fake AuthPort in use-case tests and route tests that only
need authenticated/anonymous branches. Keep Better Auth itself covered through
the app's auth route tests and provider integration tests.
Deploy the Better Auth database schema and secret configuration before enabling authenticated routes. Keep business authorization in feature policies and use Beignet auth hooks only for HTTP-boundary authentication.
AuthPort<User, Session>The provider implements the auth port interface exported by
@beignet/core/ports:
getSession(req: Request): Promise<AuthSession<User, Session> | null>Get the current session from a Request. Returns null if not authenticated.
const session = await ctx.ports.auth.getSession(req);
if (session) {
console.log(session.user);
}
getUser(req: Request): Promise<User | null>Get the current user from a Request. Returns null if not authenticated.
This is a convenience method that extracts the user from the session.
const user = await ctx.ports.auth.getUser(req);
if (user) {
console.log(user.email);
}
requireUser(req: Request): Promise<User>Require an authenticated user. Throws an error if not authenticated.
Use this in lifecycle hooks or use cases that require authentication.
const user = await ctx.ports.auth.requireUser(req);
// user is guaranteed to exist here
Throws: AuthUnauthorizedError from @beignet/core/ports if not
authenticated. When this error reaches Beignet's server runtime, it is
returned as a framework-owned 401 response with the standard error envelope.
AuthSession<User, Session>Represents an authenticated session:
interface AuthSession<User = unknown, Session = unknown> {
user: User;
session?: Session;
}
createBetterAuthProvider({ auth })Factory function that creates the provider:
function createBetterAuthProvider<User = unknown, Session = unknown>(
options: CreateBetterAuthProviderOptions<User, Session>
): BetterAuthProvider<User, Session>
Parameters:
options.auth: A Better Auth server instance configured in your applicationReturns: A Beignet provider that can be registered with the server
createBetterAuthPort({ auth, instrumentation? })Create the shared AuthPort directly when an app needs custom provider setup
around its Better Auth instance:
// server/providers.ts
import { createProvider } from "@beignet/core/providers";
import { createBetterAuthPort } from "@beignet/provider-auth-better-auth";
import { auth } from "@/lib/better-auth";
export const appAuthProvider = createProvider({
name: "app-auth",
setup({ ports }) {
return {
ports: {
auth: createBetterAuthPort({ auth, instrumentation: ports }),
},
};
},
});
export const providers = [appAuthProvider] as const;
Define and register appAuthProvider in server/providers.ts, where provider
audit can recognize the lower-level adapter as valid registration, and keep
auth deferred in infra/port-wiring.ts. Outside lifecycle provider wiring,
pass a Beignet provider instrumentation target explicitly when the adapter
should emit the same redacted auth events. The adapter does not own or stop the
Better Auth instance.
Use createAuthHooks(...) from @beignet/core/server to enforce auth beside
the contract-to-use-case wiring:
import { defineRouteGroup } from "@/lib/routes";
const users = defineContractGroup();
const getProfile = users
.get("/api/profile")
.responses({
200: z.object({ name: z.string() }),
})
.meta({ auth: "required" });
const auth = createAuthHooks<AppContext>()({
resolve: ({ ctx }) => {
return ctx.auth ? { user: ctx.auth.user } : null;
},
});
export const userRoutes = defineRouteGroup({
name: "users",
hooks: [auth.required()],
routes: [{ contract: getProfile, handle: getProfileHandler }],
});
You can wrap requireUser to throw custom error types:
class UnauthorizedError extends Error {
constructor() {
super("Unauthorized");
this.name = "UnauthorizedError";
}
}
export const requireAuth = async (
req: Request,
auth: { requireUser: (req: Request) => Promise<unknown> },
) => {
try {
return await auth.requireUser(req);
} catch (error) {
throw new UnauthorizedError();
}
};
Configure multiple strategies through Better Auth when its plugins cover the
required session, bearer, or API-key behavior. When an app needs authentication
outside Better Auth, compose that logic in an app-owned AuthPort adapter or
HTTP auth hook. Use createBetterAuthPort(...) when the app needs direct
wiring for its existing Better Auth instance.
The provider maintains full type safety for your custom User type:
type MyUser = {
id: string;
email: string;
role: "admin" | "user";
};
const authProvider = createBetterAuthProvider<MyUser>({ auth });
// Later, in your routes:
const user = await ctx.ports.auth.requireUser(req);
// user.role is typed as "admin" | "user"
Better Auth provides its own route handlers for login, signup, etc. You can mount these alongside your Beignet routes:
// app/api/auth/[...all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/better-auth";
// Better Auth handles /api/auth/*
export const { GET, POST } = toNextJsHandler(auth);
// Your Beignet routes handle /api/app/*
// (mounted separately)
See the Better Auth documentation for details on route configuration.
For Next.js 16 app shells, a root proxy.ts can improve redirects by checking
for the presence of the Better Auth session cookie before rendering protected
UI routes:
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*"],
};
This is only an optimistic UX gate. Keep Beignet's server context, auth hooks, policies, and use-case helpers as the real authorization boundary, because a cookie-only check does not validate the session.
import { betterAuth } from "better-auth";
import { createNextServer } from "@beignet/next";
import { createBetterAuthProvider } from "@beignet/provider-auth-better-auth";
import { initialPorts } from "@/infra/port-wiring";
import { appContext } from "@/server/context";
import { routes } from "@/server/routes";
const auth = betterAuth({ database: db });
const server = await createNextServer({
ports: initialPorts,
providers: [createBetterAuthProvider({ auth })],
context: appContext,
routes,
});
import { AuthUnauthorizedError } from "@beignet/core/ports";
const authHook = {
name: "auth",
beforeHandle: async ({ ctx }) => {
if (!ctx.auth) {
throw new AuthUnauthorizedError();
}
return { ctx: { ...ctx, user: ctx.auth.user } };
},
};
const server = await createNextServer({
ports: initialPorts,
providers: [createBetterAuthProvider({ auth })],
hooks: [authHook],
context: appContext,
routes,
});
const listData = async ({ ctx }) => {
const user = ctx.auth?.user;
if (user) {
return {
status: 200,
body: { data: getPersonalizedData(user) },
};
}
return {
status: 200,
body: { data: getPublicData() },
};
};
MIT