Skip to content

Authentication - Getting Started ​

Glasswork provides a focused auth module that integrates Better Auth for authentication with CASL for authorization, giving you type-safe, role-based access control with minimal setup.

For multi-tenant applications, Row Level Security adds an optional PostgreSQL tenant boundary alongside CASL permissions. Set up authentication and abilities here first, then follow that guide for database policies and tenant context.

After reading this guide, you will know:

  • How to configure Better Auth with Prisma
  • How to define CASL abilities for authorization
  • How to set up auth middleware for routes
  • How to integrate auth with OpenAPI documentation

Quick Start ​

1. Install Dependencies ​

bash
npm install better-auth @casl/ability @casl/prisma
bash
pnpm add better-auth @casl/ability @casl/prisma
bash
yarn add better-auth @casl/ability @casl/prisma

2. Generate Prisma Schema ​

Better Auth requires tables for users, sessions, and accounts. Generate the schema using the Better Auth CLI:

bash
npx @better-auth/cli@latest generate

This adds the required models to your schema.prisma. Run the migration:

bash
npx prisma migrate dev --name add-auth-tables

3. Configure Better Auth ​

Create your Better Auth configuration using the Prisma adapter:

typescript
// src/auth/auth.config.ts
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { openAPI } from 'better-auth/plugins';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export const auth = betterAuth({
  database: prismaAdapter(prisma, {
    provider: 'postgresql', // or 'sqlite', 'mysql'
  }),
  emailAndPassword: {
    enabled: true,
  },
  plugins: [
    openAPI(), // Enables OpenAPI spec generation
  ],
  user: {
    additionalFields: {
      role: {
        type: 'string',
        defaultValue: 'member',
        input: false,
      },
    },
  },
});

Keep authorization fields server-managed

Better Auth accepts additional fields from public auth requests unless you set input: false. Always disable client input for authorization data such as roles, tenant IDs, and permissions. Assign privileged values through a trusted administrative flow instead.

Performance Tip

Enable database joins for 2-3x performance improvements on session queries:

typescript
export const auth = betterAuth({
  experimental: { joins: true },
  // ... rest of config
});

4. Define Abilities ​

Create CASL abilities to define what each role can do:

typescript
// src/auth/abilities.ts
import { defineRoleAbilities, type AuthUser } from 'glasswork/auth';
import type { Subjects } from '@casl/prisma';
import type { User, Project, Organization } from '@prisma/client';

type AppSubjects = Subjects<{
  User: User;
  Project: Project;
  Organization: Organization;
}> | 'all';

type AppAction = 'create' | 'read' | 'update' | 'delete' | 'manage';
type AppRole = 'admin' | 'member' | 'guest';

export const abilities = defineRoleAbilities<AppSubjects, AppAction, AppRole>({
  admin: ({ can }) => {
    can('manage', 'all');
  },

  member: ({ can, cannot, user }) => {
    can('read', 'Organization', { id: user.tenantId });
    can('read', 'Project', { organizationId: user.tenantId });
    can('create', 'Project', { organizationId: user.tenantId });
    can('update', 'Project', { createdBy: user.id });
    cannot('delete', 'Project');
  },

  guest: () => {
    // No abilities
  },
});

export type AppAbility = ReturnType<typeof abilities.for>;

5. Create Auth Middleware ​

Wire up the middleware that validates sessions and builds abilities:

typescript
// src/auth/auth.middleware.ts
import { createAuthMiddleware, createBetterAuthProvider } from 'glasswork/auth';
import { auth } from './auth.config';
import { abilities } from './abilities';

// Create the provider that wraps Better Auth
const provider = createBetterAuthProvider({
  auth,
  // The provider reads Better Auth's cookie configuration, including HTTPS prefixes.
  // Set cookieName only when an explicit override is needed.
  mapUser: (user) => ({
    id: user.id as string,
    email: user.email as string,
    role: (user.role as string) ?? 'member',
    tenantId: user.organizationId as string | undefined,
  }),
});

// Create the middleware
export const authMiddleware = createAuthMiddleware({
  provider,
  buildAbility: (user) => abilities.for(user),
  guestAbility: () => abilities.forRole('guest'),
  allowGuest: false,
});

6. Mount Better Auth Routes ​

Mount Better Auth's handler to expose auth endpoints:

typescript
// src/auth/auth.routes.ts
import { Hono } from 'hono';
import { auth } from './auth.config';

const router = new Hono();

// Mount all Better Auth routes
router.all('/*', (c) => auth.handler(c.req.raw));

export { router as authRoutes };

7. Mount the Auth Routes ​

Mount the public Better Auth routes before handling requests:

typescript
// src/app.ts
import { bootstrap } from 'glasswork/core';
import { authRoutes } from './auth/auth.routes';

const { app } = await bootstrap(AppModule, {
  // ... options
});

// Mount auth routes
app.route('/auth', authRoutes);

Hono middleware only applies to routes registered after that middleware. Because bootstrap() registers module routes, adding app.use('/api/*', authMiddleware()) after bootstrap does not protect those routes. Apply auth middleware inside each protected route factory before registering its handlers, as shown below.

8. Protected Routes with Authorization ​

Use the authorize option to enforce permissions:

typescript
import { createRoutes, route } from 'glasswork/http';
import type { AuthenticatedAuthContext, AuthSession, AuthUser } from 'glasswork/auth';
import { authMiddleware } from './auth/auth.middleware';
import type { AppAbility } from './auth/abilities';

type AppRouteContext = AuthenticatedAuthContext<AuthUser, AuthSession, AppAbility>;

export const projectRoutes = createRoutes<
  { projectService: ProjectService },
  AppRouteContext
>((router, services, route) => {
  // Apply auth middleware
  router.use('*', authMiddleware());

  // List projects - requires 'read' permission on 'Project'
  router.get('/', ...route({
    authorize: { action: 'read', subject: 'Project' },
    responses: { 200: ProjectListSchema },
    handler: async ({ ability }) => {
      return services.projectService.findAll(ability);
    },
  }));

  // Create project
  router.post('/', ...route({
    authorize: { action: 'create', subject: 'Project' },
    body: CreateProjectSchema,
    responses: { 201: ProjectSchema },
    handler: async ({ body, user }) => {
      return services.projectService.create(body, user!.id);
    },
  }));
});

Route Context ​

The auth middleware adds these values to the Hono context, available in all route handlers:

PropertyTypeDescription
userAuthUser | nullAuthenticated user, or null for guests
sessionAuthSession | nullCurrent session data
abilityAppAbilityCASL ability instance
isAuthenticatedbooleanWhether user is authenticated

Pass your concrete AuthenticatedAuthContext as the second createRoutes type argument when allowGuest: false guarantees an authenticated request. Public or guest-capable routes should use AuthContext, whose user and session remain nullable. Without a concrete context argument, Glasswork deliberately exposes the broad default Hono context types.

Access these in route handlers:

typescript
handler: async ({ user, ability, isAuthenticated }) => {
  if (!isAuthenticated) {
    // Handle guest access
  }

  // Use ability for permission checks
  if (ability.can('delete', 'Project')) {
    // Show delete option
  }
}

Environment Variables ​

Configure Better Auth via environment variables:

env
# Required
DATABASE_URL=postgresql://...

# Optional - Session configuration
BETTER_AUTH_SECRET=your-secret-key  # For JWT signing

Next Steps ​

Released under the MIT License.