Authentik Authentication Pattern¶
Standard authentication pattern for all homelab applications using Authentik SSO with conditional auth modes for developer flexibility.
Overview¶
This pattern enables: - Single Sign-On (SSO) across all applications via Authentik - Offline/local development without Authentik connectivity - Role-based access control (RBAC) via Authentik groups - Supabase RLS integration for data isolation
Architecture¶
┌─────────────────────────────────────────────────────────────────────────────┐
│ AUTH_MODE Environment Variable │
├─────────────────────────────────┬───────────────────────────────────────────┤
│ none │ authentik │
├─────────────────────────────────┼───────────────────────────────────────────┤
│ No auth checks │ Real OAuth/OIDC flow │
│ All routes open │ Requires network access to auth.internal │
│ useSession() returns null │ Full SSO with groups/RBAC │
├─────────────────────────────────┼───────────────────────────────────────────┤
│ Use for: │ Use for: │
│ • UI/styling work │ • Integration testing │
│ • Layout prototyping │ • Pre-deploy verification │
│ • Offline development │ • Staging & Production │
└─────────────────────────────────┴───────────────────────────────────────────┘
Why Two Modes (Not Three)?¶
We intentionally keep this simple:
none- Pure UI development when you can't reach Authentikauthentik- Real auth via VPN/network access to homelab
Why no mock mode? Mock mode adds complexity:
- Custom hooks needed to wrap useSession()
- Client/server session state divergence
- More code to maintain and port to new apps
Instead, developers connect to the homelab via VPN for auth testing. This is simpler and tests real auth flows.
Security Guardrails¶
These safeguards prevent accidental deployment with weak auth:
1. Build-Time Validation¶
// next.config.ts
if (process.env.NODE_ENV === 'production' && process.env.AUTH_MODE !== 'authentik') {
throw new Error('Production builds require AUTH_MODE=authentik')
}
2. Runtime Warning Banner¶
When AUTH_MODE=none, a visible yellow banner appears:
3. Startup Logging¶
4. CI/CD Enforcement¶
GitHub Actions should validate:
- name: Validate auth mode
run: |
if [[ "$AUTH_MODE" != "authentik" ]]; then
echo "ERROR: Production deployment requires AUTH_MODE=authentik"
exit 1
fi
File Structure¶
Standard auth file layout for all applications:
src/
├── lib/
│ └── auth/
│ ├── index.ts # Main exports, mode switching
│ ├── config.ts # Auth configuration & validation
│ ├── providers/
│ │ └── authentik.ts # Real Authentik NextAuth config
│ ├── middleware.ts # Route protection middleware factory
│ ├── rbac.ts # RBAC helpers (tiers, groups)
│ └── supabase-bridge.ts # JWT bridge for Supabase RLS
├── components/
│ └── auth/
│ ├── session-provider.tsx # React context wrapper
│ ├── login-button.tsx # Conditional login UI
│ ├── user-menu.tsx # User dropdown with logout
│ └── dev-banner.tsx # Auth mode warning banner
├── app/
│ ├── login/
│ │ └── page.tsx # Login page
│ └── api/
│ └── auth/
│ ├── mode/
│ │ └── route.ts # Returns current auth mode
│ └── [...nextauth]/
│ └── route.ts # NextAuth API handlers
├── middleware.ts # Root middleware (imports from lib/auth)
└── types/
└── next-auth.d.ts # NextAuth type extensions
Environment Variables¶
Required for All Modes¶
# Auth mode: 'none' | 'authentik'
AUTH_MODE=none
# NextAuth secret (required even for none mode for session signing)
AUTH_SECRET=generate-with-openssl-rand-base64-32
Required for Authentik Mode¶
AUTH_MODE=authentik
# NextAuth
AUTH_URL=http://localhost:3000 # Or production URL
AUTH_SECRET=your-secret-here
AUTH_TRUST_HOST=true
# Authentik OIDC
AUTHENTIK_ISSUER=https://auth.internal/application/o/{app-name}/
AUTHENTIK_CLIENT_ID=your-client-id
AUTHENTIK_CLIENT_SECRET=your-client-secret
Supabase Integration (All Modes)¶
NEXT_PUBLIC_SUPABASE_URL=http://10.89.97.214:8000
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ...
SUPABASE_JWT_SECRET=your-jwt-secret # For signing RLS tokens
Core Implementation¶
1. Auth Configuration (lib/auth/config.ts)¶
const VALID_AUTH_MODES = ['none', 'authentik'] as const
export type AuthMode = (typeof VALID_AUTH_MODES)[number]
export function getAuthMode(): AuthMode {
const mode = process.env.AUTH_MODE || 'authentik'
if (!VALID_AUTH_MODES.includes(mode as AuthMode)) {
console.error(`Invalid AUTH_MODE: ${mode}. Defaulting to 'authentik'`)
return 'authentik'
}
// Production safety check
if (process.env.NODE_ENV === 'production' && mode !== 'authentik') {
throw new Error('Production requires AUTH_MODE=authentik')
}
return mode as AuthMode
}
export function isAuthDisabled(): boolean {
return getAuthMode() === 'none'
}
2. Main Auth Export (lib/auth/index.ts)¶
import { getAuthMode, isAuthDisabled } from './config'
// Re-export utilities
export { getAuthMode, isAuthDisabled } from './config'
export * from './rbac'
// Conditional auth based on mode
export async function auth() {
const mode = getAuthMode()
if (mode === 'none') {
return null
}
// Authentik mode: use real NextAuth
const { auth: authentikAuth } = await import('./providers/authentik')
return authentikAuth()
}
// Export signIn/signOut for authentik mode
export async function signIn(provider?: string, options?: any) {
const mode = getAuthMode()
if (mode !== 'authentik') {
console.warn('[AUTH] signIn called in non-authentik mode')
return
}
const { signIn: nextSignIn } = await import('./providers/authentik')
return nextSignIn(provider, options)
}
export async function signOut(options?: any) {
const mode = getAuthMode()
if (mode !== 'authentik') {
console.warn('[AUTH] signOut called in non-authentik mode')
return
}
const { signOut: nextSignOut } = await import('./providers/authentik')
return nextSignOut(options)
}
3. Middleware Factory (lib/auth/middleware.ts)¶
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { getAuthMode } from './config'
export interface MiddlewareConfig {
publicRoutes?: string[]
loginPath?: string
}
export function createAuthMiddleware(config: MiddlewareConfig = {}) {
const { publicRoutes = ['/login', '/api/auth', '/api/health'], loginPath = '/login' } = config
return async function middleware(request: NextRequest) {
const mode = getAuthMode()
const { pathname } = request.nextUrl
// None mode: allow everything
if (mode === 'none') {
return NextResponse.next()
}
// Check if public route
const isPublicRoute = publicRoutes.some(route =>
pathname.startsWith(route) || pathname === route
)
if (isPublicRoute) {
return NextResponse.next()
}
// Authentik mode: check real session
const { auth } = await import('./providers/authentik')
const session = await auth()
if (!session) {
const loginUrl = new URL(loginPath, request.url)
loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
}
}
4. Authentik Provider (lib/auth/providers/authentik.ts)¶
import NextAuth from 'next-auth'
import type { NextAuthConfig } from 'next-auth'
const config: NextAuthConfig = {
providers: [
{
id: 'authentik',
name: 'Authentik',
type: 'oidc',
issuer: process.env.AUTHENTIK_ISSUER,
clientId: process.env.AUTHENTIK_CLIENT_ID,
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET,
authorization: { params: { scope: 'openid profile email groups' } },
profile(profile) {
return {
id: profile.sub,
name: profile.name || profile.preferred_username,
email: profile.email,
image: profile.picture,
groups: profile.groups || [],
}
},
},
],
callbacks: {
jwt({ token, user, account }) {
if (account && user) {
token.accessToken = account.access_token
token.idToken = account.id_token
token.groups = (user as any).groups || []
token.authentikId = user.id
}
return token
},
session({ session, token }) {
if (session.user) {
session.user.id = token.authentikId as string
session.user.groups = token.groups as string[]
}
session.accessToken = token.accessToken as string
session.idToken = token.idToken as string
return session
},
},
pages: {
signIn: '/login',
},
trustHost: true,
}
export const { auth, signIn, signOut, handlers } = NextAuth(config)
5. Dev Banner Component (components/auth/dev-banner.tsx)¶
'use client'
import { useEffect, useState } from 'react'
export function DevAuthBanner() {
const [authMode, setAuthMode] = useState<string | null>(null)
const [dismissed, setDismissed] = useState(false)
useEffect(() => {
fetch('/api/auth/mode')
.then(res => res.json())
.then(data => setAuthMode(data.mode))
.catch(() => setAuthMode('unknown'))
}, [])
if (!authMode || authMode === 'authentik' || dismissed) {
return null
}
return (
<div className="fixed top-0 left-0 right-0 z-50 bg-yellow-500 text-yellow-950 text-center py-1.5 text-sm font-medium flex items-center justify-center gap-2">
<span>Dev Mode: Authentication disabled (AUTH_MODE=none)</span>
<button
onClick={() => setDismissed(true)}
className="ml-2 px-2 py-0.5 text-xs bg-yellow-600 hover:bg-yellow-700 text-yellow-50 rounded"
>
Dismiss
</button>
</div>
)
}
Supabase RLS Integration¶
The auth bridge creates Supabase-compatible JWTs signed with SUPABASE_JWT_SECRET, allowing RLS policies to access user claims.
JWT Bridge (lib/auth/supabase-bridge.ts)¶
import { createClient } from '@supabase/supabase-js'
import type { SupabaseClient } from '@supabase/supabase-js'
import jwt from 'jsonwebtoken'
import { getAuthMode } from './config'
type AnySupabaseClient = SupabaseClient<any, any, any>
const tokenCache = new Map<string, { token: string; expiresAt: number }>()
interface User {
id: string
email?: string | null
groups?: string[]
}
interface AuthContext {
user: User | null
supabase: AnySupabaseClient | null
rlsEnabled: boolean
}
export async function getAuthContextWithRLS(): Promise<AuthContext> {
const mode = getAuthMode()
// None mode: no auth context
if (mode === 'none') {
return { user: null, supabase: null, rlsEnabled: false }
}
// Authentik mode: get real session
const { auth } = await import('./providers/authentik')
const session = await auth()
if (!session?.user?.id) {
return { user: null, supabase: null, rlsEnabled: false }
}
const user: User = {
id: session.user.id,
email: session.user.email,
groups: session.user.groups || [],
}
// Create RLS-enabled client
const supabase = await createAuthenticatedSupabaseClient(user)
if (!supabase) {
return { user, supabase: createServiceRoleClient(), rlsEnabled: false }
}
return { user, supabase, rlsEnabled: true }
}
async function createAuthenticatedSupabaseClient(user: User): Promise<AnySupabaseClient | null> {
const supabaseJwt = createSupabaseJWT(user)
if (!supabaseJwt) return null
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
db: { schema: 'your_schema' }, // Replace with app schema
global: { headers: { Authorization: `Bearer ${supabaseJwt}` } },
}
)
}
function createSupabaseJWT(user: User): string | null {
const jwtSecret = process.env.SUPABASE_JWT_SECRET
if (!jwtSecret) {
console.error('[auth-bridge] SUPABASE_JWT_SECRET not configured')
return null
}
const cacheKey = `${user.id}-${user.groups?.join(',') || ''}`
const cached = tokenCache.get(cacheKey)
if (cached && cached.expiresAt > Date.now() + 5 * 60 * 1000) {
return cached.token
}
const now = Math.floor(Date.now() / 1000)
const payload = {
iss: 'your-app-name', // Replace with app name
sub: user.id,
aud: 'authenticated',
iat: now,
exp: now + 3600,
role: 'authenticated',
email: user.email,
groups: user.groups || [],
}
try {
const token = jwt.sign(payload, jwtSecret, { algorithm: 'HS256' })
tokenCache.set(cacheKey, { token, expiresAt: Date.now() + 55 * 60 * 1000 })
return token
} catch (err) {
console.error('[auth-bridge] Failed to create JWT:', err)
return null
}
}
export function createServiceRoleClient(): AnySupabaseClient {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ db: { schema: 'your_schema' } } // Replace with app schema
)
}
RBAC Helpers¶
Group-Based Access (lib/auth/rbac.ts)¶
interface User {
groups?: string[]
}
// Standard tier hierarchy (customize per app)
export const TIERS = {
OWNER: 'tier-owner',
FAMILY: 'tier-family',
FRIENDS: 'tier-friends',
GUESTS: 'tier-guests',
} as const
export type Tier = (typeof TIERS)[keyof typeof TIERS]
export const TIER_HIERARCHY: Tier[] = [
TIERS.OWNER,
TIERS.FAMILY,
TIERS.FRIENDS,
TIERS.GUESTS,
]
export function isAdmin(user: User | null | undefined): boolean {
return user?.groups?.includes(TIERS.OWNER) ?? false
}
export function hasGroup(user: User | null | undefined, group: string): boolean {
return user?.groups?.includes(group) ?? false
}
export function hasAnyGroup(user: User | null | undefined, groups: string[]): boolean {
if (!user?.groups) return false
return groups.some(group => user.groups!.includes(group))
}
export function getUserTier(user: User | null | undefined): Tier | null {
if (!user?.groups) return null
for (const tier of TIER_HIERARCHY) {
if (user.groups.includes(tier)) {
return tier
}
}
return null
}
export function hasTierAccess(user: User | null | undefined, requiredTier: Tier): boolean {
const userTier = getUserTier(user)
if (!userTier) return false
const userTierIndex = TIER_HIERARCHY.indexOf(userTier)
const requiredTierIndex = TIER_HIERARCHY.indexOf(requiredTier)
return userTierIndex <= requiredTierIndex // Lower index = higher privilege
}
Usage Examples¶
Server Component¶
import { auth } from '@/lib/auth'
import { isAdmin } from '@/lib/auth/rbac'
import { redirect } from 'next/navigation'
export default async function AdminPage() {
const session = await auth()
if (!session?.user || !isAdmin(session.user)) {
redirect('/unauthorized')
}
return <div>Admin content</div>
}
Client Component¶
'use client'
import { useSession } from 'next-auth/react'
import { isAdmin } from '@/lib/auth/rbac'
export function AdminPanel() {
const { data: session, status } = useSession()
if (status === 'loading') return <div>Loading...</div>
if (!session?.user || !isAdmin(session.user)) return null
return <div>Admin panel</div>
}
API Route¶
import { NextResponse } from 'next/server'
import { getAuthContextWithRLS } from '@/lib/auth/supabase-bridge'
export async function GET() {
const { user, supabase } = await getAuthContextWithRLS()
if (!user || !supabase) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// RLS policies automatically filter based on user
const { data, error } = await supabase.from('items').select('*')
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json(data)
}
Testing Different Auth Modes¶
Quick Mode Switching¶
# No auth - pure UI development (offline/no VPN)
AUTH_MODE=none npm run dev
# Real Authentik - integration testing (requires VPN to homelab)
AUTH_MODE=authentik npm run dev
Testing RBAC with Real Auth¶
Connect to homelab via VPN, then: - Log in as your admin account (tier-owner group) - Log in with a test user in different groups - Verify access controls work correctly
Authentik Application Setup¶
For each app using this pattern, create an Authentik application:
- Create OAuth2/OpenID Provider:
- Name:
{app-name}-provider - Authorization flow:
default-authorization-flow - Client ID: (auto-generated)
- Client Secret: (auto-generated)
- Redirect URIs:
http://localhost:3000/api/auth/callback/authentikhttps://{app}.internal/api/auth/callback/authentik
-
Scopes:
openid profile email groups -
Create Application:
- Name:
{app-name} - Slug:
{app-name} - Provider:
{app-name}-provider -
Launch URL:
https://{app}.internal -
Configure Group Mappings (if using RBAC):
- Property Mappings should include groups claim
- Standard scope
groupsshould be enabled
Migration Checklist¶
When migrating an existing app to this pattern:
- [ ] Install dependencies:
next-auth@beta jsonwebtoken - [ ] Copy auth file structure from template
- [ ] Update
.env.localwith auth variables - [ ] Add
AUTH_MODEvalidation tonext.config.ts - [ ] Update root
middleware.tsto use auth middleware - [ ] Wrap app with
SessionProviderin root layout - [ ] Add
DevAuthBannercomponent - [ ] Update API routes to use
getAuthContextWithRLS() - [ ] Create Authentik application
- [ ] Test both auth modes (none and authentik)
- [ ] Update CI/CD to enforce
AUTH_MODE=authentikin production
Related Documentation¶
- Migrating to Authentik - Step-by-step migration guide
- Supabase Integration - Database setup and RLS
- App Conventions - Standard project structure