better-auth

Authentication patterns with Better Auth for this Next.js application. Covers session authentication, API key authentication, dual auth, OAuth, and testing. Use this skill when implementing or modifyi

SKILL.md

GitHub

Better Auth Skill

Authentication patterns and best practices for this Next.js application using Better Auth.

Architecture Overview

core/lib/
├── auth.ts                   # Better Auth configuration
├── auth-client.ts            # Client-side auth utilities
└── api/auth/
    ├── dual-auth.ts          # Dual authentication (API Key + Session)
    ├── index.ts              # API key validation
    └── scopes.ts             # Permission scopes

app/hooks/
└── useAuth.ts                # Client-side auth hook

packages/core/src/routes/(auth)/
├── login/                    # Login page
├── register/                 # Registration page
├── verify-email/             # Email verification
└── forgot-password/          # Password reset

When to Use This Skill

  • Implementing authentication flows (login, register, logout)
  • Adding API key authentication to endpoints
  • Implementing role-based access control
  • Setting up OAuth providers
  • Testing authentication flows
  • Securing API endpoints with dual auth

Authentication Methods

1. Session Authentication (Dashboard)

Used for authenticated users in the dashboard UI.

// Server-side session check
import { auth } from '@nextsparkjs/core/lib/auth'

const session = await auth.api.getSession({
  headers: request.headers
})

if (!session?.user) {
  return redirect('/login')
}
// Client-side with useAuth hook
import { useAuth } from '@/hooks/useAuth'

function Dashboard() {
  const { user, session, isLoading, isAuthenticated } = useAuth()

  if (isLoading) return <Loading />
  if (!isAuthenticated) return redirect('/login')

  return <div>Welcome, {user.name}</div>
}

2. API Key Authentication (External APIs)

Used for external integrations and programmatic access.

// API Key headers
// Option 1: Authorization header
Authorization: Bearer sk_live_xxxxxxxxxxxxx

// Option 2: x-api-key header
x-api-key: sk_live_xxxxxxxxxxxxx

// CRITICAL: Always include team context
x-team-id: team-tmt-001

3. Dual Authentication (Unified Endpoints)

All /api/v1/ endpoints support both authentication methods.

import { authenticateRequest, createAuthFailureResponse } from '@nextsparkjs/core/lib/api/auth/dual-auth'

export async function GET(request: NextRequest) {
  // Tries API Key first, then Session. Declares the scope this route
  // needs — an API key without it is rejected here, fails closed (#93).
  // See "API Key Scopes" below for the full contract.
  const authResult = await authenticateRequest(request, { requiredScope: 'users:read' })

  if (!authResult.success) {
    return createAuthFailureResponse(authResult)
  }

  // authResult contains:
  // - type: 'api-key' | 'session'
  // - user: { id, email, role, defaultTeamId }
  // - scopes: string[] (for API keys) or ['all'] (for sessions)
}

User Roles vs Team Roles

CRITICAL: Two Separate Role Systems

This application has two completely separate role systems:

Aspect User Roles (App-Level) Team Roles (Team-Level)
Stored in user.role column teamMembers.role column
Scope Global (entire app) Per-team membership
Extensible ❌ NO - Fixed in core ✅ YES - Themes can add custom roles
Purpose System access (routes) Entity permissions within team

User Roles (App-Level, FIXED)

// 3 fixed system roles - CANNOT be extended by themes
type UserRole = 'member' | 'superadmin' | 'developer'
Role Hierarchy Access Routes
member 1 Standard user /dashboard/*
superadmin 99 System admin, bypasses team permissions /superadmin/*
developer 100 Full access, debugging APIs /devtools/*

Check user role:

import { roleHelpers } from '@nextsparkjs/core/lib/role-helpers'

// CORRECT: Use roleHelpers for user roles
if (roleHelpers.isDeveloper(user.role)) {
  // Access to /devtools/*
}
if (roleHelpers.isSuperAdmin(user.role)) {
  // Access to /superadmin/*, bypass team checks
}

// WRONG: Don't check user roles like team roles
if (membership.hasRole('superadmin')) {} // Wrong context!

Team Roles (Team-Level, EXTENSIBLE)

// Core team roles (protected, always available)
type CoreTeamRole = 'owner' | 'admin' | 'member' | 'viewer'

// Theme can add custom roles
type TeamRole = CoreTeamRole | 'editor' | 'contributor' | string
Role Hierarchy Description
owner 100 Team creator, all permissions, cannot be removed
admin 50 Team management, member roles, billing
member 10 Standard entity access
viewer 1 Read-only access
editor* 5 Theme-defined: Edit without delete
contributor* 3 Theme-defined: Limited create/edit

*Custom roles defined in permissions.config.ts

Check team role:

import { MembershipService } from '@nextsparkjs/core/lib/services'

// Get membership context
const membership = await MembershipService.get(userId, teamId)

// Check team role
if (membership.hasRole('admin')) {
  // Can manage team members
}

// Check specific permission
if (membership.can('products.create')) {
  // Can create products in this team
}

Why Two Systems?

User Roles Team Roles
Control where users can go Control what users can do
Route-level access Entity-level permissions
Fixed for security Flexible for business logic
Checked by middleware Checked by API/UI

User Metadata (users_metas)

User metadata is stored in a separate table (users_metas) using the standard entity meta pattern.

Schema

-- core/migrations/003_user_metas.sql
CREATE TABLE "users_metas" (
  id TEXT PRIMARY KEY,
  "userId" TEXT NOT NULL REFERENCES "users"(id) ON DELETE CASCADE,
  "metaKey" TEXT NOT NULL,
  "metaValue" JSONB NOT NULL DEFAULT '{}',
  "dataType" TEXT,              -- 'string' | 'number' | 'boolean' | 'json'
  "isPublic" BOOLEAN DEFAULT FALSE,
  "isSearchable" BOOLEAN DEFAULT FALSE,
  CONSTRAINT users_metas_unique_key UNIQUE ("userId", "metaKey")
);

Common Meta Keys

Key Type Description isPublic
preferences.theme string 'light' | 'dark' | 'system' false
preferences.language string Locale code ('en', 'es') false
preferences.timezone string Timezone identifier false
preferences.sidebarCollapsed boolean UI state false
onboarding.completed boolean Onboarding status false
onboarding.step number Current onboarding step false
notifications.email boolean Email notification preference false
profile.bio string User biography true
profile.socialLinks json Social media links true

Usage

import { MetaService } from '@nextsparkjs/core/lib/services'

// Get user meta
const theme = await MetaService.get('users', userId, 'preferences.theme')

// Set user meta
await MetaService.set('users', userId, 'preferences.theme', 'dark')

// Get multiple metas
const prefs = await MetaService.getMany('users', userId, [
  'preferences.theme',
  'preferences.language'
])

// Delete meta
await MetaService.delete('users', userId, 'onboarding.step')

Client-Side Hook

import { useUserSettings } from '@nextsparkjs/core/hooks/useUserSettings'

function SettingsPage() {
  const { settings, updateSetting, isLoading } = useUserSettings()

  const toggleTheme = () => {
    updateSetting('preferences.theme', settings.theme === 'dark' ? 'light' : 'dark')
  }

  return (
    <Button onClick={toggleTheme}>
      {settings.theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
    </Button>
  )
}

RLS Policies

  • Owner access: Users can read/write their own metas
  • Admin access: Superadmins can read/write any user's metas
  • Public read: Metas with isPublic = true are readable by anyone

API Key Scopes

// Scope format: {entity}:{action}
// Examples:
// - tasks:read | tasks:write | tasks:delete
// - teams:read | teams:write | teams:delete
// - billing:read | billing:write
// - admin:api-keys | admin:users | admin:devtools
// - *              → Superadmin full access, satisfies any required scope

Scope enforcement fails closed (#93)

authenticateRequest / validateAndAuthenticateRequest enforce scopes at the entry point, not per-handler:

  • { requiredScope: 'tasks:read' } (string, or an array meaning "any of") — a key missing it gets rejected with 403 INSUFFICIENT_SCOPE.
  • { allowAnyScope: true } — explicit opt-out for a route that's genuinely scope-agnostic. Say why in a one-line comment.
  • Neither declared — 403 SCOPE_NOT_DECLARED, and the route is named in the server log.
  • Session auth is never scope-gated.
const authResult = await authenticateRequest(request, { requiredScope: 'tasks:read' })
if (!authResult.success) {
  return createAuthFailureResponse(authResult) // picks 401 vs 403 and the right code
}

hasRequiredScope(authResult, scope) still exists for an optional, second check inside a handler — don't use it to re-check the exact scope already passed as requiredScope, that's always true by construction.

Themes and plugins add their own scopes via app.config.ts → api.scopes (the default theme adds ai:read, ai:write, social:read, social:write).

Test Users

Theme Users (password: Test1234)

DEFAULT_THEME_USERS = {
  OWNER: 'carlos.mendoza@tmt.dev',      // Everpoint Labs (owner)
  ADMIN: 'james.wilson@tmt.dev',        // Everpoint Labs (admin)
  MEMBER: 'emily.johnson@tmt.dev',      // Everpoint Labs (member)
  EDITOR: 'diego.ramirez@tmt.dev',      // Everpoint Labs (editor)
  VIEWER: 'sarah.davis@tmt.dev',        // Ironvale Global (viewer)
}

Core Users (password: Pandora1234)

CORE_USERS = {
  SUPERADMIN: 'superadmin@tmt.dev',     // Global superadmin
  DEVELOPER: 'developer@tmt.dev',       // Global developer
}

Test Teams

Team ID Team Name Description
team-tmt-001 Everpoint Labs Primary test team
team-tmt-002 Ironvale Global Secondary test team

Registration Control

Registration behavior is configurable at the theme level via auth in app.config.ts.

Registration Modes

Mode Email Signup Google OAuth Signup Email Login Google Login Signup Page
open (default) Yes Yes Yes Yes Visible
domain-restricted No Only allowed domains No Only allowed domains Hidden
domain-open Only allowed domains Only allowed domains Only allowed domains Only allowed domains Visible
invitation-only Via invite Via invite Yes Configurable Invite only

Theme Configuration

// config/app.config.ts

// Option 1: Open registration (default, no config needed)
auth: {
  registration: { mode: 'open' },
}

// Option 2: Only Google OAuth for specific domains (no email+password)
auth: {
  registration: {
    mode: 'domain-restricted',
    allowedDomains: ['nextspark.dev', 'mycompany.com'],
  },
}

// Option 3: Email+password AND Google OAuth, restricted to specific domains
auth: {
  registration: {
    mode: 'domain-open',
    allowedDomains: ['nextspark.dev', 'mycompany.com'],
  },
}

// Option 4: Invitation-only (integrates with single-tenant teams mode)
auth: {
  registration: { mode: 'invitation-only' },
}

Types

// packages/core/src/lib/config/types.ts
type RegistrationMode = 'open' | 'domain-restricted' | 'domain-open' | 'invitation-only'

type AuthLoginMethod = 'email-otp' | 'google' | 'email-password'

interface AuthConfig {
  registration: {
    mode: RegistrationMode
    allowedDomains?: string[]  // For 'domain-restricted' and 'domain-open' modes
  }
  providers?: {
    google?: { enabled?: boolean }
  }
  // Login methods offered by the templates, in priority order.
  // DEFAULT = passwordless preset ['email-otp', 'google'] (no password field).
  methods?: AuthLoginMethod[]
  // Server-side switch for Better Auth's password endpoints. Undeclared: on when
  // methods lists 'email-password', and outside production. `true`/`false` decide.
  emailAndPassword?: { enabled?: boolean }
  // Session duration/renewal in seconds (defaults: 7d / 1d / 5-min cookie cache)
  session?: { expiresIn?: number; updateAge?: number; cookieCache?: { enabled?: boolean; maxAge?: number } }
}

Login Presets (auth.methods)

Preset methods Login UI
passwordless (default) ['email-otp', 'google'] Google button + email → 6-digit code. No password field, no /signup page (first OTP sign-in creates the account).
classic ['email-password', 'google'] Google button + email/password form, signup + reset flows.
both ['email-otp', 'email-password', 'google'] First email method is shown first; the user can switch.

Helpers: resolveAuthMethods, isPasswordlessPreset, getPrimaryEmailMethod, AUTH_PRESETS in lib/auth/auth-methods.ts. Client side use PUBLIC_AUTH_CONFIG.methods. The OTP email goes through the configured email provider (Resend by default) — see docs/06-authentication/12-passwordless-preset.md.

Helper Functions

import {
  isRegistrationOpen,
  isDomainAllowed,
  isGoogleAuthEnabled,
  shouldBlockSignup,
  getPublicAuthConfig,
} from '@nextsparkjs/core/lib/auth/registration-helpers'

Enforcement Points

  1. Route handler (packages/core/src/routes/api/auth/[...all]/route.ts): Blocks email signup for domain-restricted and invitation-only modes
  2. Database hook (auth.ts → databaseHooks.user.create.before): Validates email domain for domain-restricted and domain-open modes; blocks signup in invitation-only mode when team exists
  3. Session hook (auth.ts → databaseHooks.session.create.before): Validates email domain on every login for domain-restricted and domain-open modes
  4. Signup page (packages/core/src/routes/(auth)/signup/page.tsx): Redirects to /login for domain-restricted and invitation-only
  5. LoginForm: Hides Google OAuth button and signup link based on mode
  6. SignupForm: Hides Google button when disabled

Client-Side Config

Use PUBLIC_AUTH_CONFIG (from config-sync.ts) in client components. This strips allowedDomains for security.

import { PUBLIC_AUTH_CONFIG } from '@nextsparkjs/core/lib/config/config-sync'

// PUBLIC_AUTH_CONFIG.registration.mode → 'open' | 'domain-restricted' | 'domain-open' | 'invitation-only'
// PUBLIC_AUTH_CONFIG.providers.google.enabled → boolean

Authentication Flows

Email/Password Registration

1. User submits registration form
2. Validate email format and password strength
3. Create user with emailVerified = false
4. Send verification email with token
5. User clicks verification link
6. Set emailVerified = true
7. Redirect to login

Email/Password Login

1. User submits login form
2. Validate credentials
3. Check emailVerified = true
4. Create session
5. Set HttpOnly cookie
6. Redirect to dashboard

Google OAuth

1. User clicks "Sign in with Google"
2. Redirect to Google OAuth
3. Google returns profile data
4. Map profile to user (mapProfileToUser)
5. Create/update user record
6. Create session
7. Redirect to dashboard

Password Reset

1. User requests password reset
2. Generate reset token (1-hour expiry)
3. Send reset email
4. User clicks reset link
5. Validate token
6. User enters new password
7. Update password hash (bcrypt)
8. Invalidate token
9. Redirect to login

Security Rules

Password Requirements

// Minimum requirements
const passwordSchema = z.string()
  .min(8, 'Password must be at least 8 characters')
  .max(128, 'Password cannot exceed 128 characters')
  .regex(/[A-Z]/, 'Password must contain uppercase letter')
  .regex(/[0-9]/, 'Password must contain a number')

Session Configuration

// Session settings
const sessionConfig = {
  expiresIn: 60 * 60 * 24 * 7,  // 7 days
  updateAge: 60 * 60 * 24,      // Update every day
  cookieOptions: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax'
  }
}

Rate Limiting

Action Limit Window
Login attempts 5 15 minutes
Password reset 3 1 hour
Verification email 5 1 hour
API requests 100 1 minute

Token Expiration

Token Type Expiration
Email verification 24 hours
Password reset 1 hour
Session 7 days
API Key No expiration (revocable)

useAuth Hook

import { useAuth } from '@/hooks/useAuth'

function Component() {
  const {
    // State
    user,             // Current user object
    session,          // Session data
    isLoading,        // Loading state
    isAuthenticated,  // Boolean auth status

    // Actions
    signIn,           // (email, password) => Promise
    signUp,           // (email, password, name) => Promise
    signOut,          // () => Promise
    resetPassword,    // (email) => Promise
    updateProfile,    // (data) => Promise
  } = useAuth()

  // Example: Sign in
  const handleLogin = async () => {
    try {
      await signIn(email, password)
      router.push('/dashboard')
    } catch (error) {
      setError(error.message)
    }
  }
}

Protected Routes

Middleware Protection

// middleware.ts
export { auth as middleware } from "@nextsparkjs/core/lib/auth"

export const config = {
  matcher: [
    '/dashboard/:path*',
    '/profile/:path*',
    '/api/user/:path*'
  ]
}

Page-Level Protection

// templates/dashboard/page.tsx
import { auth } from '@nextsparkjs/core/lib/auth'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const session = await auth.api.getSession({
    headers: headers()
  })

  if (!session) {
    redirect('/login')
  }

  return <Dashboard user={session.user} />
}

Error Codes

Code HTTP Status Description
AUTHENTICATION_REQUIRED 401 No auth credentials provided
INVALID_API_KEY 401 API key invalid or expired
INVALID_CREDENTIALS 401 Wrong email/password
EMAIL_NOT_VERIFIED 401 User hasn't verified email
SIGNUP_RESTRICTED 403 Invitation-only / single-tenant mode (registration requires invite)
INSUFFICIENT_PERMISSIONS 403 User lacks required role/permission (generic)
INSUFFICIENT_SCOPE 403 Valid API key, but missing the route's declared requiredScope (#93)
SCOPE_NOT_DECLARED 403 Valid API key, but the route declared no requiredScope/allowAnyScope — fails closed (#93)
TEAM_CONTEXT_REQUIRED 400 Missing x-team-id header
SESSION_EXPIRED 401 Session no longer valid

Testing Authentication

Cypress Session Pattern

describe('Authenticated Tests', () => {
  beforeEach(() => {
    cy.session('owner-session', () => {
      cy.visit('/login')
      cy.get('[data-cy="email-input"]').type('carlos.mendoza@tmt.dev')
      cy.get('[data-cy="password-input"]').type('Test1234')
      cy.get('[data-cy="login-submit"]').click()
      cy.url().should('include', '/dashboard')
    }, {
      validate: () => {
        cy.visit('/dashboard')
        cy.url().should('include', '/dashboard')
      }
    })
  })

  it('should access protected page', () => {
    cy.visit('/dashboard/tasks')
    cy.get('[data-cy="tasks-table"]').should('exist')
  })
})

API Test Pattern

describe('API Auth Tests', () => {
  const API_KEY = Cypress.env('SUPERADMIN_API_KEY')
  const TEAM_ID = 'team-tmt-001'

  it('should authenticate with API key', () => {
    cy.request({
      method: 'GET',
      url: '/api/v1/tasks',
      headers: {
        'Authorization': `Bearer ${API_KEY}`,
        'x-team-id': TEAM_ID
      }
    }).then((response) => {
      expect(response.status).to.eq(200)
    })
  })

  it('should reject without API key', () => {
    cy.request({
      method: 'GET',
      url: '/api/v1/tasks',
      failOnStatusCode: false
    }).then((response) => {
      expect(response.status).to.eq(401)
    })
  })
})

OAuth Configuration

Google OAuth

// core/lib/auth.ts
socialProviders: {
  google: {
    clientId: process.env.GOOGLE_CLIENT_ID!,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    mapProfileToUser: (profile) => ({
      email: profile.email,
      name: profile.name,
      image: profile.picture,
      emailVerified: profile.email_verified
    })
  }
}

Environment Variables

# Better Auth
BETTER_AUTH_SECRET=your-secret-key
NEXT_PUBLIC_APP_URL=http://localhost:5173

# Google OAuth
GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret

# Email (Resend)
RESEND_API_KEY=your-resend-key
EMAIL_FROM=noreply@yourdomain.com

Anti-Patterns

// NEVER: Store passwords in plain text
user.password = 'plain-text-password'

// CORRECT: Use bcrypt via Better Auth
await auth.api.signUp({ email, password, name })

// NEVER: Skip email verification
if (!user.emailVerified) {
  // Allow access anyway... WRONG!
}

// CORRECT: Require verification
if (!user.emailVerified) {
  redirect('/verify-email')
}

// NEVER: Expose API keys in client code
const API_KEY = 'sk_live_xxxxx' // In browser!

// CORRECT: Use environment variables (server-side only)
const API_KEY = process.env.API_KEY // Server only

// NEVER: Skip team context for entity operations
// Missing x-team-id header

// CORRECT: Always include team context
headers: { 'x-team-id': teamId }

// NEVER: Trust client-provided user data
const userId = request.body.userId // User can fake this!

// CORRECT: Get user from authenticated session
// (a real route would pass { requiredScope } here — see "API Key Scopes" above)
const { user } = await authenticateRequest(request, { requiredScope: 'users:read' })

Checklist

Before finalizing authentication code:

  • Uses Better Auth for all auth operations
  • Sessions use HttpOnly, Secure cookies
  • Email verification required for email/password
  • Password meets minimum requirements (8+ chars)
  • API endpoints use dual auth pattern
  • authenticateRequest/validateAndAuthenticateRequest calls declare { requiredScope } or { allowAnyScope: true } — undeclared routes fail closed with 403 SCOPE_NOT_DECLARED (#93)
  • Protected routes redirect unauthenticated users
  • Error responses use proper status codes
  • Rate limiting implemented for sensitive endpoints
  • OAuth profile mapping configured correctly
  • Test users documented and working

Related Skills

  • cypress-e2e - UAT testing with session authentication
  • cypress-api - API testing with API key authentication
  • nextjs-api-development - API route patterns