Browse documentation

Authenticate with better-auth

Connect a better-auth instance to FlowPanel and resolve the role from your own tables.

This guide connects an existing better-auth instance to FlowPanel and restricts the admin to users whose role is admin.

Prerequisites

better-auth must already work in the host Next.js application, with its instance exported from a module of your own — usually lib/auth.ts:

import { betterAuth } from "better-auth";

export const auth = betterAuth({
  database: /* your adapter */,
});

flowpanel init recognises better-auth in package.json and scaffolds the wiring below for you, pointed at the module it found.

Configure FlowPanel auth

import { defineAdmin } from "@flowpanel/kit";
import { withBetterAuth } from "@flowpanel/kit/auth";
import { auth } from "@/lib/auth";

export default defineAdmin({
  adapter,
  auth: withBetterAuth({
    auth,
    requireRole: "admin",
    signInUrl: "/sign-in",
    forbiddenUrl: "/",
  }),
  resources,
});

withBetterAuth calls auth.api.getSession({ headers: req.headers }) with the request FlowPanel is actually serving, so cookie-based sessions work identically in page renders and API routes. By default the role comes from session.user.role and falls back to guest, and the actor id for audit rows and per-user rate limiting comes from session.user.id.

Resolve the role from the database

better-auth's session carries whatever you put in it. If the role lives in your own table rather than the session, role may return a promise — FlowPanel awaits it before any role gate runs:

auth: withBetterAuth({
  auth,
  requireRole: "admin",
  role: async (session) => {
    const id = (session?.user as { id?: string } | undefined)?.id;
    if (!id) return "guest";
    const member = await db.query.members.findFirst({
      where: (m, { eq }) => eq(m.userId, id),
      columns: { role: true },
    });
    return member?.role ?? "guest";
  },
}),

This runs on every request and page render, so keep it to one indexed read. Cache it in your own layer if that read is expensive; FlowPanel does not cache it for you, because a stale role is an authorization bug.

Verify the boundary

Test all three cases:

  1. no session: the admin page follows signInUrl, while API requests are rejected;
  2. signed in without admin: the page follows forbiddenUrl, while API requests return forbidden;
  3. signed in as admin: the page and resource API succeed.

Also call one action route as a non-admin. A hidden button is not proof; the server response is.

Production notes and failure modes

  • Grant the admin role deliberately. requireRole is the admin-wide gate; per-resource requireRole and access narrow it further.
  • A role resolver that throws fails the request closed rather than falling back to guest.
  • An async role that reaches a database makes every admin request depend on it. If that database is the one the admin manages, an outage takes the sign-in path down with it.
  • Keep allowUnauthenticated off unless the deployment is already fronted by an authenticating proxy.