Authentication System
This document explains the authentication architecture, which relies on Better-Auth to provide a secure, type-safe identity layer including 2FA, Passkeys, and Session Management.
Architecture Overview
We strictly separate Server-Side Auth and Client-Side Auth.
Core Components
| Component | Location | Purpose |
|---|---|---|
| Server Auth | src/lib/auth.ts | Better-Auth instance with Prisma adapter |
| Client Auth | src/lib/auth-client.ts | React hooks (useSession) |
| Middleware | src/middleware.ts | Route protection (Edge) |
| Session Utils | src/lib/session.ts | Server-side session helpers |
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Client │────▶│ Middleware │────▶│ Server Action │
│ (React) │ │ (Edge Runtime) │ │ or API Route │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │ │
│ │ │
▼ ▼ ▼
useSession() Session Check checkPermission()Protection Layers
Layer 1: Middleware (Edge)
The middleware runs before any component rendering.
Location: src/middleware.ts
export async function middleware(request: NextRequest) {
const session = await auth.api.getSession({
headers: request.headers,
});
// Protect dashboard routes
if (request.nextUrl.pathname.startsWith('/dashboard')) {
if (!session) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
return NextResponse.next();
}WARNING
Middleware does NOT check fine-grained permissions (RBAC), only authentication status.
Layer 2: API Route Handlers
Every sensitive API route MUST verify the session explicitly.
// src/app/api/jobs/route.ts
export async function POST(req: Request) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Proceed with permission check...
await checkPermission(PERMISSIONS.JOBS.WRITE);
// Handle request...
}Layer 3: Server Actions
Server Actions act as the public API for the frontend and must:
- Validate the session
- Validate permissions using
checkPermission
// src/app/actions/job.ts
"use server";
export async function createJob(data: JobInput) {
// 1. Check permission (throws if unauthorized)
await checkPermission(PERMISSIONS.JOBS.WRITE);
// 2. Validate input
const validated = JobSchema.parse(data);
// 3. Execute business logic
return jobService.create(validated);
}Advanced Features
Two-Factor Authentication (2FA)
We use TOTP (Time-based One-Time Password).
Enabling 2FA:
- User initiates setup in Profile → Security
- Server generates a secret key
- User scans QR code with authenticator app
- User verifies with a code to confirm setup
- Secret is stored encrypted in the database
Verification Flow:
// During login, if 2FA is enabled
if (user.twoFactorEnabled) {
// Return partial session, require 2FA verification
return { requiresTwoFactor: true, tempToken: "..." };
}Passkeys (WebAuthn)
Allows passwordless login using biometric sensors (TouchID, FaceID, Windows Hello).
Registration:
- Client generates a public/private key pair
- Public key is sent to server and stored
- Private key remains securely on device
Authentication:
- Server sends a "challenge"
- Client signs it with private key
- Server verifies signature with stored public key
SSO / OIDC
See SSO Integration for detailed OIDC implementation.
Session Management
Configurable Session Duration
Session lifetime is configurable by administrators as Sessions last under Settings → Sign-in. The value is stored in the SystemSetting table under the key auth.sessionDuration (in seconds). A session ends that long after its sign-in, however often it is used.
// src/lib/auth/index.ts - the end of a session, set once when it is created
session: SESSION_OPTIONS, // from src/lib/auth/session-lifetime.ts
databaseHooks: {
session: {
create: {
before: async (session) => {
const expiresAt = sessionEndsAt(await getSessionDuration()); // reads SystemSetting
return { data: { ...session, expiresAt } };
},
},
},
},Default: 7 days (604800 seconds). Available options: 1h, 8h, 1d, 3d, 7d, 14d, 30d, 90d.
SESSION_OPTIONS sets two things better-auth would otherwise get wrong:
disableSessionRefresh: true. Left on, better-auth extends a session onceupdateAgepassed, to its ownexpiresInfrom that moment, so every length turned into seven days from the last visit and an active user was never signed out.expiresInof 90 days, the longest length. better-auth gives the session cookie thatMax-Age, so the cookie never ends before the session in the database does, which decides.
tests/unit/lib/session-lifetime.test.ts checks both against better-auth itself, with its memory adapter.
Session Listing & Revocation
Better Auth provides built-in endpoints for session management:
| Endpoint | Method | Purpose |
|---|---|---|
/api/auth/list-sessions | GET | List all sessions for the current user |
/api/auth/revoke-session | POST | Revoke a specific session by token |
/api/auth/revoke-other-sessions | POST | Revoke all sessions except the current one |
Client-side usage:
import { authClient } from "@/lib/auth/client";
// List sessions
const { data: sessions } = await authClient.listSessions();
// Revoke a specific session
await authClient.revokeSession({ token: session.token });
// Revoke all other sessions
await authClient.revokeOtherSessions();Each session record includes ipAddress, userAgent, createdAt, updatedAt, and expiresAt fields, populated automatically by Better Auth.
Server-Side Session Access
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
export async function getServerSession() {
return auth.api.getSession({
headers: await headers(),
});
}Client-Side Session Access
import { useSession } from "@/lib/auth/client";
function ProfileButton() {
const { data: session, isPending } = useSession();
if (isPending) return <Skeleton />;
if (!session) return <LoginButton />;
return <Avatar user={session.user} />;
}API Key Authentication
In addition to session-based authentication, DBackup supports stateless API key authentication for programmatic access (CI/CD, cron jobs, external tools).
API keys use Authorization: Bearer dbackup_xxx headers and are validated via SHA-256 hash lookup. They have their own permission set and never inherit SuperAdmin privileges.
All API routes support both authentication methods through the unified getAuthContext() function.
→ See API Keys & Webhooks for the full architecture, key generation, validation flow, and webhook trigger system.
Security Best Practices
- Never trust client-side checks alone - Always verify on server
- Use
getAuthContext()+checkPermissionWithContext()in API routes - Supports both session and API key auth - Use
checkPermission()in Server Actions - Session-only, defense in depth - Log authentication events - Use the Audit System
- Implement rate limiting - Prevent brute force attacks
- Secure session cookies - HttpOnly, Secure, SameSite
- API keys: hash-only storage - Never persist raw keys