Authentik OAuth/OIDC Provider Setup¶
This guide covers setting up Authentik as an OAuth2/OIDC identity provider for Next.js applications, replacing Supabase Auth.
When to Use This¶
Use Authentik OAuth when: - You want SSO across all homelab applications - The app needs access to Authentik's application list API - You want centralized user management
Continue using Supabase Auth when: - App is standalone with no SSO requirements - You need Supabase's built-in auth features (magic links, phone auth)
App Auth Status¶
Track which apps use Authentik vs Supabase Auth:
| App | Auth Method | Schema | Status |
|---|---|---|---|
| home-portal | Authentik | home_portal |
✅ Complete |
| money-tracker | Supabase Auth | money_tracker |
Pending |
| trip-planner | Supabase Auth | trip_planner |
Pending |
| rms | Supabase Auth | rms |
Pending |
New App vs Existing App¶
New app (foobar)? Skip Part 0 and Part 3. Follow: 1. Part 1: Authentik Configuration 2. Part 2: Next.js Configuration 3. Part 4: Kubernetes Deployment
For database: Use user_id TEXT from the start (not UUID), and don't enable RLS.
Existing app with Supabase Auth? Follow all parts in order.
Part 0: Discovery (Existing Apps Only)¶
Before migrating an existing app to Authentik, assess its current state.
Step 1: Find the Schema Name¶
# Check lib/supabase/client.ts for schema config
grep -r "schema:" /root/projects/<app>/lib/supabase/
Step 2: Check Current Auth Method¶
# Look for Supabase Auth usage
grep -r "auth.getUser\|supabase.auth\|createServerClient" /root/projects/<app>/
# Look for existing NextAuth
grep -r "next-auth\|NextAuth" /root/projects/<app>/
Step 3: Inventory Database Tables with user_id¶
# Check migrations for user_id columns and their types
grep -r "user_id" /root/projects/<app>/supabase/migrations/*.sql
Key things to note:
- user_id UUID → needs migration to TEXT
- RLS policies using auth.uid() → need to be dropped
- FK constraints to auth.users → need to be dropped
Step 4: Check K8s Deployment State¶
# Current secrets (need to add auth credentials)
kubectl get secret -n <app>
# Current ConfigMap (need to add AUTH_* vars)
kubectl get configmap <app>-config -n <app> -o yaml
Prerequisites¶
Before starting, ensure:
- CoreDNS configured for
.internalzone - Pods must resolveauth.internal - See CoreDNS .internal Zone Forwarding
-
Verify:
kubectl exec <any-pod> -- nslookup auth.internal -
Authentik running at
http://auth.internal -
Supabase running with your app's schema created
Architecture¶
┌─────────────────┐ ┌─────────────────┐
│ Next.js App │────▶│ Authentik │
│ (home-portal) │ │ OAuth Provider │
└────────┬────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ Supabase │ ← App data only (not auth)
│ (service role) │ Manual user_id filtering
└─────────────────┘
Key insight: Authentik handles authentication, Supabase handles data with service role + manual filtering.
Part 1: Authentik Configuration¶
Step 1: Create OAuth2/OIDC Provider¶
- Login to Authentik:
http://auth.internal - Navigate to Applications → Providers → Create
- Select OAuth2/OpenID Provider
- Configure:
| Setting | Value |
|---|---|
| Name | <app-name>-oauth (e.g., home-portal-oauth) |
| Authentication flow | default-authentication-flow |
| Authorization flow | default-provider-authorization-explicit-consent |
| Client type | Confidential |
| Client ID | Auto-generated (copy this) |
| Client Secret | Auto-generated (copy this) |
| Redirect URIs | http://<app>.internal/api/auth/callback/authentik |
| Signing Key | authentik Self-signed Certificate |
Scopes (under Advanced): openid, profile, email
Step 2: Create Application¶
- Navigate to Applications → Applications → Create
- Configure:
| Setting | Value |
|---|---|
| Name | Display name (e.g., "Home Portal") |
| Slug | URL-safe identifier (e.g., home-portal) |
| Provider | Select the OAuth provider you created |
| Launch URL | http://<app>.internal |
- Add policy bindings for access control (optional)
Step 3: Note Your Credentials¶
AUTHENTIK_CLIENT_ID=<from provider>
AUTHENTIK_CLIENT_SECRET=<from provider>
AUTHENTIK_ISSUER=http://auth.internal/application/o/<app-slug>/
Part 2: Next.js Configuration¶
Installation¶
Environment Variables¶
# .env.local
# Supabase
NEXT_PUBLIC_SUPABASE_URL=http://10.89.97.214:8000
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon key>
SUPABASE_SERVICE_ROLE_KEY=<service role key>
# NextAuth v5
AUTH_URL=http://<app>.internal # Or IP:port for dev
AUTH_SECRET=<generate with: openssl rand -base64 32>
AUTH_TRUST_HOST=true
# Authentik OAuth
AUTHENTIK_CLIENT_ID=<from Authentik>
AUTHENTIK_CLIENT_SECRET=<from Authentik>
AUTHENTIK_ISSUER=http://auth.internal/application/o/<app-slug>/
File 1: Auth Configuration¶
Create lib/auth.ts (or src/lib/auth.ts if using src directory):
import NextAuth from 'next-auth'
import type { NextAuthConfig } from 'next-auth'
export const authConfig: 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' } },
profile(profile) {
return {
id: profile.sub,
name: profile.name || profile.preferred_username,
email: profile.email,
image: profile.picture,
}
},
},
],
callbacks: {
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token
token.authentikId = account.providerAccountId
}
return token
},
async session({ session, token }) {
session.accessToken = token.accessToken as string
session.user.id = token.authentikId as string
return session
},
},
pages: {
signIn: '/login',
},
trustHost: true,
}
export const { handlers, auth, signIn, signOut } = NextAuth(authConfig)
File 2: Type Definitions¶
Create types/next-auth.d.ts:
import 'next-auth'
declare module 'next-auth' {
interface Session {
accessToken?: string
user: {
id: string
name?: string | null
email?: string | null
image?: string | null
}
}
}
declare module 'next-auth/jwt' {
interface JWT {
accessToken?: string
authentikId?: string
}
}
File 3: API Route Handler¶
Create app/api/auth/[...nextauth]/route.ts:
File 4: Middleware¶
Create middleware.ts (root of project):
import { auth } from '@/lib/auth'
import { NextResponse } from 'next/server'
export default auth((req) => {
const isLoggedIn = !!req.auth
const { pathname } = req.nextUrl
const isPublicRoute =
pathname === '/login' ||
pathname.startsWith('/api/auth') ||
pathname.startsWith('/api/health')
if (isPublicRoute) {
if (isLoggedIn && pathname === '/login') {
return NextResponse.redirect(new URL('/', req.url))
}
return NextResponse.next()
}
if (!isLoggedIn) {
const loginUrl = new URL('/login', req.url)
loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
})
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
File 5: Login Page¶
Create app/login/page.tsx:
import { signIn } from '@/lib/auth'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
export const dynamic = 'force-dynamic'
export default function LoginPage({
searchParams,
}: {
searchParams: Promise<{ callbackUrl?: string }>
}) {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl text-center">App Name</CardTitle>
<CardDescription className="text-center">
Sign in to access your account
</CardDescription>
</CardHeader>
<CardContent>
<form
action={async () => {
'use server'
const params = await searchParams
await signIn('authentik', {
redirectTo: params.callbackUrl || '/',
})
}}
>
<Button type="submit" className="w-full">
Sign in with Authentik
</Button>
</form>
</CardContent>
</Card>
</div>
)
}
File 6: Supabase Auth Helper¶
Create lib/supabase/authenticated.ts:
import { auth } from '@/lib/auth'
import { createClient as createSupabaseClient } from '@supabase/supabase-js'
export async function getAuthenticatedUser() {
const session = await auth()
if (!session?.user?.id) {
return null
}
return session.user
}
export function createAuthenticatedClient() {
return createSupabaseClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
db: {
schema: 'your_app_schema', // e.g., 'home_portal'
},
}
)
}
export async function getAuthContext() {
const user = await getAuthenticatedUser()
if (!user) {
return { user: null, supabase: null }
}
return {
user,
supabase: createAuthenticatedClient(),
}
}
Using in API Routes¶
import { NextResponse } from 'next/server'
import { getAuthContext } from '@/lib/supabase/authenticated'
export async function GET() {
const { user, supabase } = await getAuthContext()
if (!user || !supabase) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('services')
.select('*')
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json(data)
}
Part 3: Database Migration¶
If migrating from Supabase Auth, you need to:
1. Drop RLS policies that use auth.uid()
2. Convert user_id columns from UUID to TEXT
3. Disable RLS (we use service role + manual filtering)
Step 1: Generate Migration from Discovery¶
Based on your Part 0 discovery, create a migration file:
Step 2: Write Migration SQL¶
Use this template, replacing with your actual table/policy names from discovery:
-- Migration: migrate_to_authentik_auth.sql
-- Generated from Part 0 discovery of existing RLS policies
SET search_path TO your_schema; -- e.g., trip_planner
-- Drop all RLS policies that use auth.uid()
DROP POLICY IF EXISTS "trips_user_policy" ON trips;
DROP POLICY IF EXISTS "itineraries_user_policy" ON itineraries;
DROP POLICY IF EXISTS "locations_user_policy" ON locations;
-- ... add all policies found in discovery
-- Convert user_id from UUID to TEXT (Authentik uses string IDs)
ALTER TABLE trips
ALTER COLUMN user_id TYPE TEXT USING user_id::TEXT;
-- Disable RLS on all tables (service role bypasses anyway)
ALTER TABLE trips DISABLE ROW LEVEL SECURITY;
ALTER TABLE itineraries DISABLE ROW LEVEL SECURITY;
ALTER TABLE locations DISABLE ROW LEVEL SECURITY;
-- ... add all tables with RLS enabled
Step 3: Apply Migration¶
# Local development
npx supabase db reset
# Production (k8s Supabase)
/root/scripts/migrate-app.sh <app-name>
Note: Existing data with UUID user_ids will be preserved as text. New records will use Authentik's string-based user IDs.
Part 4: Kubernetes Deployment¶
Secrets¶
Create sealed secret with auth credentials:
# /tmp/app-secrets.yaml (don't commit plain secrets!)
apiVersion: v1
kind: Secret
metadata:
name: <app>-secrets
namespace: <app>
type: Opaque
stringData:
NEXT_PUBLIC_SUPABASE_ANON_KEY: "<key>"
SUPABASE_SERVICE_ROLE_KEY: "<key>"
AUTH_SECRET: "<key>"
AUTHENTIK_CLIENT_ID: "<id>"
AUTHENTIK_CLIENT_SECRET: "<secret>"
Seal and apply:
kubeseal --format yaml < /tmp/app-secrets.yaml > manifests/apps/<app>/secrets-sealed.yaml
kubectl apply -f manifests/apps/<app>/secrets-sealed.yaml
rm /tmp/app-secrets.yaml
ConfigMap¶
kubectl patch configmap <app>-config -n <app> --type merge -p '{
"data": {
"AUTH_URL": "http://<app>.internal",
"AUTH_TRUST_HOST": "true",
"AUTHENTIK_ISSUER": "http://auth.internal/application/o/<app-slug>/"
}
}'
Deployment Env Vars¶
Add to deployment:
kubectl patch deployment <app> -n <app> --type='json' -p='[
{"op": "add", "path": "/spec/template/spec/containers/0/env/-", "value": {"name": "SUPABASE_SERVICE_ROLE_KEY", "valueFrom": {"secretKeyRef": {"name": "<app>-secrets", "key": "SUPABASE_SERVICE_ROLE_KEY"}}}},
{"op": "add", "path": "/spec/template/spec/containers/0/env/-", "value": {"name": "AUTH_URL", "valueFrom": {"configMapKeyRef": {"name": "<app>-config", "key": "AUTH_URL"}}}},
{"op": "add", "path": "/spec/template/spec/containers/0/env/-", "value": {"name": "AUTH_SECRET", "valueFrom": {"secretKeyRef": {"name": "<app>-secrets", "key": "AUTH_SECRET"}}}},
{"op": "add", "path": "/spec/template/spec/containers/0/env/-", "value": {"name": "AUTH_TRUST_HOST", "valueFrom": {"configMapKeyRef": {"name": "<app>-config", "key": "AUTH_TRUST_HOST"}}}},
{"op": "add", "path": "/spec/template/spec/containers/0/env/-", "value": {"name": "AUTHENTIK_ISSUER", "valueFrom": {"configMapKeyRef": {"name": "<app>-config", "key": "AUTHENTIK_ISSUER"}}}},
{"op": "add", "path": "/spec/template/spec/containers/0/env/-", "value": {"name": "AUTHENTIK_CLIENT_ID", "valueFrom": {"secretKeyRef": {"name": "<app>-secrets", "key": "AUTHENTIK_CLIENT_ID"}}}},
{"op": "add", "path": "/spec/template/spec/containers/0/env/-", "value": {"name": "AUTHENTIK_CLIENT_SECRET", "valueFrom": {"secretKeyRef": {"name": "<app>-secrets", "key": "AUTHENTIK_CLIENT_SECRET"}}}}
]'
Quick Reference¶
Files Checklist¶
| File | Purpose |
|---|---|
lib/auth.ts |
NextAuth configuration |
types/next-auth.d.ts |
Type definitions |
app/api/auth/[...nextauth]/route.ts |
Auth API handler |
middleware.ts |
Route protection |
app/login/page.tsx |
Login page |
lib/supabase/authenticated.ts |
Supabase auth helper |
Environment Variables¶
| Variable | Where | Purpose |
|---|---|---|
AUTH_URL |
ConfigMap | App's public URL |
AUTH_SECRET |
Secret | Session encryption |
AUTH_TRUST_HOST |
ConfigMap | Trust proxy headers |
AUTHENTIK_ISSUER |
ConfigMap | OIDC discovery URL |
AUTHENTIK_CLIENT_ID |
Secret | OAuth client ID |
AUTHENTIK_CLIENT_SECRET |
Secret | OAuth client secret |
SUPABASE_SERVICE_ROLE_KEY |
Secret | Bypass RLS |
Troubleshooting¶
"Configuration" error on login¶
- Pod can't reach
auth.internal - Check:
kubectl exec <pod> -- nslookup auth.internal - Fix: Apply CoreDNS config, restart CoreDNS
"Invalid redirect_uri"¶
- Redirect URI in Authentik doesn't match callback URL
- Check:
curl http://<app>.internal/api/auth/providers - Fix: Add exact URI to Authentik provider
"Dynamic server usage" error¶
- Login page using
searchParamswithout dynamic export - Fix: Add
export const dynamic = 'force-dynamic'
Session not persisting¶
- Missing or wrong
AUTH_SECRET - Fix: Generate with
openssl rand -base64 32
Related Documentation¶
- CoreDNS .internal Zone Forwarding - Required prerequisite
- Authentik SSO Infrastructure - Overview
- NextAuth.js Documentation