Skip to main content
0.0.x

better-auth plugins

better-auth ships its feature set as plugins — organizations, two-factor, API keys, bearer and JWT tokens, magic links, passkeys, and more. NetScript's better-auth backend mounts them through a single typed passthrough on createNetscriptBetterAuth, so you enable a plugin the same way you would in a standalone better-auth app while NetScript keeps ownership of the Prisma-backed database adapter.

This page covers how to enable plugins, which ones run as-is, and the two prerequisites — database tables and interactive sign-in — that decide whether a given plugin is turnkey today.

Enabling a plugin

createNetscriptBetterAuth accepts a plugins array typed as better-auth's own BetterAuthOptions["plugins"]. Pass plugin instances exactly as better-auth documents them; NetScript forwards them into the underlying better-auth server while supplying the Prisma adapter itself.

import { organization } from "better-auth/plugins";
import {
  createBetterAuthBackend,
  createNetscriptBetterAuth,
} from "@netscript/auth-better-auth";

const auth = createNetscriptBetterAuth({
  prisma,
  provider: "postgresql",
  secret: Deno.env.get("BETTER_AUTH_SECRET")!,
  plugins: [organization()],
});

const backend = createBetterAuthBackend({
  auth,
  sessionTokenSecret: Deno.env.get("BETTER_AUTH_SECRET")!,
});

For better-auth options that NetScript does not surface directly, use betterAuthOptions (typed as Omit<BetterAuthOptions, "database" | "plugins">). NetScript owns database through its Prisma adapter, and plugins use the dedicated plugins field, so both are excluded from that escape hatch.

When the organization plugin is enabled, the active-organization fields better-auth writes onto the session (activeOrganizationId, role, roles, and permissions) and the user's roles flow through NetScript's authenticator onto the Principal — its scopes, roles, and claims — so downstream services and pages can authorize on organization context without any extra wiring.

What runs today, and what needs a prerequisite

A plugin enabled through the passthrough type-checks and is mounted at the better-auth layer. Whether it is runnable depends on what the plugin needs at runtime.

Stateless plugins run as-is

bearer and jwt add no tables of their own. They run through the passthrough alone, with no further setup.

Table-backed plugins need a schema migration

Interactive plugins are driven by better-auth directly

Protect a route with the resolved session

createBetterAuthBackend returns an AuthBackendPort, and the piece a page or API route needs is backend.sessions.getSession({ request }): it validates the request's better-auth session cookie through auth.api.getSession and returns the normalized AuthSession (or undefined). Map that session to a Principal with backend.principalMapper.mapSessionToPrincipal(session) and you have the scopes and roles to authorize on. The example below gates a Fresh route on an active session and an admin role, failing closed on both.

// server/auth-backend.ts — one shared backend instance for the app
import { auth } from './better-auth.ts'; // your createNetscriptBetterAuth(...) instance
import { createBetterAuthBackend } from '@netscript/auth-better-auth';

export const backend = createBetterAuthBackend({
  auth,
  sessionTokenSecret: Deno.env.get('BETTER_AUTH_SECRET')!,
});
// routes/admin/index.tsx — a page that only an authenticated admin can load
import { HttpError } from 'fresh';
import type { AuthnRequest } from '@netscript/service/auth';
import { define } from '@/utils/state.ts';
import { backend } from '../../server/auth-backend.ts';

// Adapt the Fresh Request into the AuthnRequest the backend port reads.
function toAuthnRequest(req: Request): AuthnRequest {
  const url = new URL(req.url);
  return {
    header: (name) => req.headers.get(name) ?? undefined,
    headers: () => req.headers,
    cookie: (name) =>
      req.headers.get('cookie')
        ?.split('; ')
        .find((c) => c.startsWith(`${name}=`))
        ?.slice(name.length + 1),
    method: req.method,
    path: url.pathname,
  };
}

export const handler = define.handlers(async (ctx) => {
  // Resolve the session from the request's better-auth cookie.
  const session = await backend.sessions.getSession({
    request: toAuthnRequest(ctx.req),
  });

  // Fail closed: no active session → redirect to sign-in, never render anonymously.
  if (!session || session.state !== 'active') {
    return ctx.redirect('/api/v1/auth/signin');
  }

  // Map to a NetScript Principal and authorize on its roles.
  const { principal } = backend.principalMapper.mapSessionToPrincipal(session);
  if (!principal.roles.includes('admin')) {
    throw new HttpError(403);
  }

  return { data: { subject: principal.subject, roles: principal.roles } };
});

export default define.page<typeof handler>(({ data }) => (
  <main>
    <h1>Admin dashboard</h1>
    <p>Signed in as {data.subject} — roles: {data.roles.join(', ')}</p>
  </main>
));

Because getSession returns a typed AuthSession | undefined and the state check is explicit, the route cannot silently degrade to an anonymous render — the same fail-loud discipline the authentication page describes for the backend port. A machine-to-machine API route follows the identical shape: swap the redirect for throw new HttpError(401).

Where to go next

Auth model — how Principals, sessions, and backends fit together.

Authentication — the authentication capability overview.