Skip to content

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:

  1. CoreDNS configured for .internal zone - Pods must resolve auth.internal
  2. See CoreDNS .internal Zone Forwarding
  3. Verify: kubectl exec <any-pod> -- nslookup auth.internal

  4. Authentik running at http://auth.internal

  5. 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

  1. Login to Authentik: http://auth.internal
  2. Navigate to Applications → Providers → Create
  3. Select OAuth2/OpenID Provider
  4. 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

  1. Navigate to Applications → Applications → Create
  2. 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
  1. 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

npm install next-auth@beta

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:

import { handlers } from '@/lib/auth'

export const { GET, POST } = handlers

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:

npx supabase migration new migrate_to_authentik_auth

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 searchParams without dynamic export
  • Fix: Add export const dynamic = 'force-dynamic'

Session not persisting

  • Missing or wrong AUTH_SECRET
  • Fix: Generate with openssl rand -base64 32