Authentication
Library: BetterAuth v1.5.6 Strategy: OAuth-only (Discord + GitHub), database-backed sessions via Prisma adapter
1. Architecture
graph LR
subgraph Client["CLIENT"]
UI["Sign-in Page"] -->|"signIn('discord')"| AC["authClient"]
AC -->|"OAuth redirect"| PROVIDER["Discord / GitHub"]
end
subgraph Server["SERVER"]
CALLBACK["/api/auth/callback"] -->|"Create session"| BA["BetterAuth"]
BA -->|"Prisma adapter"| DB["PostgreSQL"]
BA -->|"Set cookie"| REDIRECT["Redirect to /profile"]
end
subgraph Guards["AUTH GUARDS"]
LAYOUT["Layout Guard<br/><small>/profile/*</small>"] -->|"redirect"| SIGNIN["/sign-in"]
ADMIN["Admin Guard<br/><small>Server Actions</small>"] -->|"check role"| DENY["403 Forbidden"]
OWNER["Ownership Guard<br/><small>API keys, account</small>"] -->|"check userId"| DENY
end
PROVIDER -->|"OAuth callback"| CALLBACK
style Client fill:#1a1a2e,stroke:#a855f7,color:#c084fc
style Server fill:#0f1a0f,stroke:#22c55e,color:#4ade80
style Guards fill:#1c1917,stroke:#f59e0b,color:#fbbf24
Authentication is split across three files:
Server Configuration
src/auth.js
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
export const auth = betterAuth({
secret: process.env.BETTER_AUTH_SECRET,
baseURL: process.env.BETTER_AUTH_URL,
database: prismaAdapter(db, { provider: 'postgresql' }),
socialProviders: {
discord: { clientId: ..., clientSecret: ... },
github: { clientId: ..., clientSecret: ... },
},
user: {
additionalFields: {
role: { type: 'string', defaultValue: 'user', input: false },
},
},
});
Key points:
- Prisma adapter connects to PostgreSQL (same
dbsingleton used across the app) rolefield added toUsermodel as a custom BetterAuth field (not user-settable via input)- No email/password or magic link authentication configured
Client Utilities
src/auth-client.js
| Export | Type | Purpose |
|---|---|---|
authClient | Object | BetterAuth client instance from createAuthClient() |
signIn | Function | signIn(provider, options?) — triggers OAuth flow. Default callbackURL: '/profile' |
signOut | Function | Signs out and redirects to / |
useSession | Hook | React hook for accessing session in client components |
Route Handler
src/app/api/auth/[...all]/route.js
import { toNextJsHandler } from 'better-auth/next-js';
export const { GET, POST } = toNextJsHandler(auth);
Delegates all /api/auth/* requests to BetterAuth. Handles OAuth callbacks, session management, and sign-out.
2. Session Handling
Server-Side (Server Components & Server Actions)
import { auth } from '@/auth';
import { headers } from 'next/headers';
const session = await auth.api.getSession({ headers: await headers() });
// Returns: { user: { id, name, email, image, role, banned, ... }, session: { id, token, expiresAt, ... } }
// Or: null (not authenticated)
Sessions are stored in the Session table with an opaque token field and expiresAt timestamp. BetterAuth manages session cookies automatically.
Client-Side (Client Components)
'use client';
import { useSession } from '@/auth-client';
function MyComponent() {
const { data: session, isPending } = useSession();
// session?.user.name, session?.user.role, etc.
}
Session Revocation
await auth.api.revokeSession({ headers: await headers() });
Used in the profile layout to revoke sessions for banned users.
3. Auth Guard Patterns
Layout Guard (Protected Routes)
src/app/profile/layout.jsx
const session = await auth.api.getSession({ headers: await headers() });
if (!session || !session.user) {
redirect('/sign-in');
}
if (session.user.banned) {
await auth.api.revokeSession({ headers: await headers() });
redirect('/');
}
All /profile/* routes are protected by this layout. Unauthenticated users are redirected to /sign-in. Banned users have their session revoked and are redirected to /.
Admin Guard (Server Actions)
src/db/queries/admin.mjs
async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || !session.user) return { error: 'Not authenticated' };
if (session.user.role !== 'admin') return { error: 'Forbidden' };
return { user: session.user };
}
Used by all admin server actions (getAllUsers, updateUserRole, toggleUserBan, etc.). Returns { user } on success or { error } on failure.
Ownership Guard (Server Actions)
src/db/queries/account.mjs, src/db/queries/api.mjs
const session = await auth.api.getSession({ headers: await headers() });
if (!session || !session.user) return { errors: { auth: 'Not authenticated' } };
if (session.user.id !== userId) return { errors: { auth: 'Not authorized' } };
Ensures users can only act on their own resources (API keys, account data).
4. Sign-In / Sign-Out Flow
Sign-In
src/app/sign-in/page.jsx — client component with Discord and GitHub OAuth buttons.
Flow:
- User clicks "Sign in with Discord" or "Sign in with GitHub"
signIn('discord')from@/auth-clientcallsauthClient.signIn.social({ provider: 'discord', callbackURL: '/profile' })- BetterAuth redirects to the provider's OAuth consent page
- On approval, provider redirects to
/api/auth/callback/discord - BetterAuth creates/links the
Account, creates aSession, sets the session cookie - User is redirected to the
callbackURL(defaults to/profile)
Sign-Out
src/shared/components/Auth/Auth.jsx — <SignIn> and <SignOut> button components.
Flow:
- User clicks "Sign out" in the header
signOut()from@/auth-clientcallsauthClient.signOut()withonSuccessredirect to/- BetterAuth revokes the session and clears the cookie
Navigation Integration
src/shared/components/Navigation/Navigation.jsx is a server component that checks the session at request time. When authenticated, it renders the user's avatar and a <SignOut> button. When unauthenticated, it renders a <SignIn> button.
5. Role System
| Role | Access |
|---|---|
user | Profile, API keys, reviews, account actions |
admin | Everything above + admin section on the profile page (/profile) |
Roles are stored in User.role (string, default "user"). Admins can change roles via updateUserRole() but cannot demote themselves. The admin section on the profile page is conditionally rendered when session.user.role === 'admin'.
6. API Key Authentication
API keys provide a separate authentication mechanism for the /api/h1/rebroadcast endpoint. They are independent of BetterAuth sessions.
src/db/queries/validateApiKey.mjs
graph LR
REQ["API Request<br/><small>Authorization: Bearer key</small>"] --> EXTRACT["Extract key"]
EXTRACT --> HASH["SHA-256(key)"]
HASH --> LOOKUP["Query ApiKey table<br/><small>by hash</small>"]
LOOKUP -->|"Found + enabled"| OK["✓ Authenticated<br/><small>{userId, keyId}</small>"]
LOOKUP -->|"Not found"| FAIL["✗ Invalid"]
LOOKUP -->|"Disabled"| DISABLED["✗ Disabled"]
style REQ fill:#1e293b,stroke:#3b82f6,color:#60a5fa
style OK fill:#0f1a0f,stroke:#22c55e,color:#4ade80
style FAIL fill:#2d1b1b,stroke:#ef4444,color:#f87171
style DISABLED fill:#2d1b1b,stroke:#ef4444,color:#f87171
Flow:
- Client sends
Authorization: Bearer <key>header validateApiKey()extracts the key, computesSHA-256(key)- Looks up the hash in the
ApiKeytable - Returns
{ userId, keyId }on success, or error state ('missing','invalid','disabled')
Management: Users can create (max 5), enable/disable, and delete API keys from their profile dashboard. The plaintext key is shown once at creation and never stored.
See API Reference section 4 for the rebroadcast endpoint's full authentication flow.
7. Environment Variables
Auth is optional — if BETTER_AUTH_SECRET is absent, all auth features are disabled (no sign-in UI, /profile redirects to home, auth API returns 503). If BETTER_AUTH_SECRET is present, all other auth vars are required (partial config throws at startup).
| Variable | Required | Description |
|---|---|---|
BETTER_AUTH_SECRET | No (all-or-none) | Session encryption secret (128+ chars). Controls whether auth is enabled. |
BETTER_AUTH_URL | If auth enabled | Base URL (e.g., http://localhost:3000) |
AUTH_DISCORD_ID | If auth enabled | Discord OAuth app client ID |
AUTH_DISCORD_SECRET | If auth enabled | Discord OAuth app client secret |
AUTH_GITHUB_ID | If auth enabled | GitHub OAuth app client ID |
AUTH_GITHUB_SECRET | If auth enabled | GitHub OAuth app client secret |
When auth is disabled:
src/auth.jsexportsauth = null(BetterAuth is never initialized)/api/auth/*returns 503{ error: "Auth is not configured on this instance" }/profile/*routes redirect to/UserSectionis not rendered in the navigation (server component guard)- Query files (
admin.mjs,account.mjs,api.mjs) return early with{ error: 'Auth not configured' }
See Infrastructure section 5 for the complete environment variable reference.
8. Migration Notes
The project migrated from NextAuth.js v5 (beta) to BetterAuth in v0.25.0 (2026-04-04). Key changes:
| Aspect | NextAuth.js v5 | BetterAuth |
|---|---|---|
| Route handler path | /api/auth/[...nextauth] | /api/auth/[...all] |
| Server session | auth() (direct call) | auth.api.getSession({ headers: await headers() }) |
| Client auth | Server actions | Client-side via authClient.signIn.social() |
| Env: secret | AUTH_SECRET | BETTER_AUTH_SECRET |
| Env: trust host | AUTH_TRUST_HOST (required) | Not needed |
| Env: base URL | Not needed | BETTER_AUTH_URL |
| Account fields | provider / providerAccountId | providerId / accountId |
| Session token field | sessionToken | token |
| Session expiry field | expires | expiresAt |
| Verification model | VerificationToken (no PK) | Verification (has id PK) |
| WebAuthn | Authenticator model | Removed |
| Email auth | Nodemailer (commented out) | Removed entirely |
Breaking: The migration dropped and recreated all auth tables. Existing users, sessions, and API keys were lost.
Cross-References
- Database Schema section 5 — auth model field definitions and constraints
- API Reference section 9 — auth route endpoints
- Infrastructure section 5 — environment variables
