Skip to content

Migrating to Authentik Authentication

Step-by-step guide for migrating applications from Supabase Auth (or no auth) to the standardized Authentik authentication pattern.

Prerequisites

  • Application already using Supabase for data storage
  • Access to Authentik admin panel (https://auth.internal/if/admin/)
  • Network access to auth.internal (for integration testing)

Overview

The migration involves: 1. Setting up Authentik application 2. Installing auth dependencies 3. Adding auth file structure 4. Updating existing code 5. Testing all auth modes 6. Deploying with proper safeguards

Estimated effort: 2-4 hours depending on app complexity

Phase 1: Authentik Setup

1.1 Create OAuth2/OpenID Provider

  1. Navigate to Applications > Providers in Authentik admin
  2. Click Create and select OAuth2/OpenID Provider
  3. Configure:
Name: {app-name}-provider
Authorization flow: default-authorization-flow
Client type: Confidential
Client ID: (copy this - auto-generated)
Client Secret: (copy this - auto-generated)

Redirect URIs:
  - http://localhost:3000/api/auth/callback/authentik
  - https://{app}.internal/api/auth/callback/authentik

Signing Key: authentik Self-signed Certificate
Subject mode: Based on the User's hashed ID

Advanced:
  Access token validity: minutes=5
  Scopes: openid, profile, email, groups

1.2 Create Application

  1. Navigate to Applications > Applications
  2. Click Create
  3. Configure:
Name: {app-name}
Slug: {app-name}
Provider: {app-name}-provider
Launch URL: https://{app}.internal

UI Settings:
  Icon: (optional - upload app icon)

1.3 (Optional) Configure Group Mappings

If your app uses RBAC with groups:

  1. Navigate to Customization > Property Mappings
  2. Verify authentik default OAuth Mapping: OpenID 'groups' exists
  3. Ensure the provider includes this mapping in its scope

1.4 Record Credentials

Save these values for .env.local:

AUTHENTIK_ISSUER=https://auth.internal/application/o/{app-slug}/
AUTHENTIK_CLIENT_ID={from step 1.1}
AUTHENTIK_CLIENT_SECRET={from step 1.1}

Phase 2: Install Dependencies

cd /root/projects/{app-name}

# Install auth dependencies
npm install next-auth@beta jsonwebtoken zod

# Type definitions
npm install -D @types/jsonwebtoken

Verify package.json:

{
  "dependencies": {
    "next-auth": "^5.0.0-beta.30",
    "jsonwebtoken": "^9.0.3",
    "zod": "^3.23.0"
  }
}

Phase 3: Add Auth File Structure

3.1 Copy Template Files

Copy the auth files from the scaffold template:

# From scaffold template
cp -r /root/tower-fleet/scaffolds/nextjs/src/lib/auth /root/projects/{app-name}/src/lib/
cp -r /root/tower-fleet/scaffolds/nextjs/src/components/auth /root/projects/{app-name}/src/components/
cp -r /root/tower-fleet/scaffolds/nextjs/src/components/layout /root/projects/{app-name}/src/components/
cp /root/tower-fleet/scaffolds/nextjs/src/types/next-auth.d.ts /root/projects/{app-name}/src/types/
cp -r /root/tower-fleet/scaffolds/nextjs/src/app/api/auth /root/projects/{app-name}/src/app/api/
cp -r /root/tower-fleet/scaffolds/nextjs/src/app/login /root/projects/{app-name}/src/app/
cp /root/tower-fleet/scaffolds/nextjs/src/middleware.ts /root/projects/{app-name}/src/

# Replace template variables
SCHEMA_NAME="your_schema"
APP_NAME="your-app"
find /root/projects/{app-name}/src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i \
  -e "s/{{SCHEMA_NAME}}/$SCHEMA_NAME/g" \
  -e "s/{{APP_NAME}}/$APP_NAME/g" {} \;

Note: For new apps, use /intents:scaffold-nextjs which includes auth automatically.

3.2 Required File Structure

Create these files (see Authentik Auth Pattern for full implementations):

src/lib/auth/
├── index.ts              # Main exports
├── config.ts             # AUTH_MODE validation
├── types.ts              # User/Session Zod schemas
├── middleware.ts         # Middleware factory
├── supabase-bridge.ts    # JWT bridge for RLS
├── rbac.ts               # Group/tier helpers
└── providers/
    ├── authentik.ts      # NextAuth config
    ├── mock.ts           # Mock provider
    └── none.ts           # Passthrough

3.3 Add Components

src/components/auth/
├── session-provider.tsx  # SessionProvider wrapper
├── login-button.tsx      # Login button component
├── user-menu.tsx         # User dropdown
└── dev-banner.tsx        # Auth mode warning

3.4 Add Types

Create src/types/next-auth.d.ts:

import 'next-auth'

declare module 'next-auth' {
  interface Session {
    accessToken?: string
    idToken?: string
    user: {
      id: string
      email: string
      name?: string
      image?: string
      groups: string[]
    }
  }

  interface User {
    groups?: string[]
  }
}

declare module 'next-auth/jwt' {
  interface JWT {
    accessToken?: string
    idToken?: string
    authentikId?: string
    groups?: string[]
  }
}

3.5 Add API Route

Create src/app/api/auth/[...nextauth]/route.ts:

import { getAuthMode } from '@/lib/auth/config'

export async function GET(request: Request) {
  const mode = getAuthMode()

  if (mode !== 'authentik') {
    return new Response('Auth disabled', { status: 404 })
  }

  const { handlers } = await import('@/lib/auth/providers/authentik')
  return handlers.GET(request)
}

export async function POST(request: Request) {
  const mode = getAuthMode()

  if (mode !== 'authentik') {
    return new Response('Auth disabled', { status: 404 })
  }

  const { handlers } = await import('@/lib/auth/providers/authentik')
  return handlers.POST(request)
}

3.6 Add Auth Mode API (for dev banner)

Create src/app/api/auth/mode/route.ts:

import { NextResponse } from 'next/server'
import { getAuthMode } from '@/lib/auth/config'

export async function GET() {
  return NextResponse.json({ mode: getAuthMode() })
}

3.7 Add Login Page

Create src/app/login/page.tsx:

import { auth, signIn } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { getAuthMode } from '@/lib/auth/config'

export default async function LoginPage({
  searchParams,
}: {
  searchParams: { callbackUrl?: string }
}) {
  const mode = getAuthMode()

  // In non-authentik modes, redirect to home
  if (mode !== 'authentik') {
    redirect(searchParams.callbackUrl || '/')
  }

  // Already logged in? Redirect
  const session = await auth()
  if (session) {
    redirect(searchParams.callbackUrl || '/')
  }

  return (
    <div className="min-h-screen flex items-center justify-center">
      <div className="p-8 rounded-lg border bg-card">
        <h1 className="text-2xl font-bold mb-6">Sign In</h1>
        <form
          action={async () => {
            'use server'
            await signIn('authentik', {
              redirectTo: searchParams.callbackUrl || '/',
            })
          }}
        >
          <button
            type="submit"
            className="w-full px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
          >
            Sign in with Authentik
          </button>
        </form>
      </div>
    </div>
  )
}

Phase 4: Update Existing Code

4.1 Update Root Middleware

Replace or update src/middleware.ts:

import { createAuthMiddleware } from '@/lib/auth/middleware'

export default createAuthMiddleware({
  publicRoutes: ['/login', '/api/auth', '/api/health'],
  loginPath: '/login',
})

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}

4.2 Update Root Layout

Update src/app/layout.tsx:

import { SessionProvider } from '@/components/auth/session-provider'
import { DevAuthBanner } from '@/components/auth/dev-banner'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <SessionProvider>
          <DevAuthBanner />
          {/* rest of your layout */}
          {children}
        </SessionProvider>
      </body>
    </html>
  )
}

4.3 Update next.config.ts

Add build-time auth validation:

import type { NextConfig } from 'next'

// Validate auth mode for production builds
if (process.env.NODE_ENV === 'production') {
  if (process.env.AUTH_MODE !== 'authentik') {
    throw new Error(
      'Production builds require AUTH_MODE=authentik. ' +
      `Current value: ${process.env.AUTH_MODE || 'undefined'}`
    )
  }
}

const nextConfig: NextConfig = {
  // ... your config
}

export default nextConfig

4.4 Update API Routes

Replace Supabase auth calls with new pattern:

Before (Supabase Auth):

import { createClient } from '@/lib/supabase/server'

export async function GET() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { data } = await supabase.from('items').select('*')
  return NextResponse.json(data)
}

After (Authentik + RLS Bridge):

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 })
  }

  const { data } = await supabase.from('items').select('*')
  return NextResponse.json(data)
}

4.5 Update Client Components

Before (Supabase Auth):

import { createClient } from '@/lib/supabase/client'

const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()

After (NextAuth):

import { useSession } from 'next-auth/react'

const { data: session } = useSession()
const user = session?.user

4.6 Update Server Components

Before:

import { createClient } from '@/lib/supabase/server'

const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()

After:

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

const session = await auth()
const user = session?.user

Phase 5: Environment Configuration

5.1 Update .env.local

# Auth Mode: 'none' | 'mock' | 'authentik'
AUTH_MODE=mock

# NextAuth (required for all modes)
AUTH_SECRET=generate-with-openssl-rand-base64-32
AUTH_URL=http://localhost:3000
AUTH_TRUST_HOST=true

# Authentik (only needed when AUTH_MODE=authentik)
AUTHENTIK_ISSUER=https://auth.internal/application/o/{app-name}/
AUTHENTIK_CLIENT_ID=your-client-id
AUTHENTIK_CLIENT_SECRET=your-client-secret

# Mock user (only used when AUTH_MODE=mock)
MOCK_USER_ID=dev-user-123
MOCK_USER_EMAIL=dev@local.test
MOCK_USER_NAME=Dev User
MOCK_USER_GROUPS=tier-owner

# Supabase (unchanged)
NEXT_PUBLIC_SUPABASE_URL=...
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
SUPABASE_SERVICE_ROLE_KEY=...
SUPABASE_JWT_SECRET=...

5.2 Generate AUTH_SECRET

openssl rand -base64 32

Phase 6: Database Migration (RLS)

If using Supabase RLS, update policies to use JWT claims from the auth bridge.

6.1 Create RLS Helper Functions

-- Create helper functions for extracting auth claims
CREATE OR REPLACE FUNCTION {schema}.auth_user_id()
RETURNS TEXT AS $$
  SELECT COALESCE(
    current_setting('request.jwt.claims', true)::json->>'sub',
    (current_setting('request.jwt.claims', true)::json->'user_metadata'->>'sub')::text
  )
$$ LANGUAGE sql STABLE;

CREATE OR REPLACE FUNCTION {schema}.user_groups()
RETURNS TEXT[] AS $$
  SELECT COALESCE(
    ARRAY(SELECT jsonb_array_elements_text(
      current_setting('request.jwt.claims', true)::jsonb->'groups'
    )),
    ARRAY[]::TEXT[]
  )
$$ LANGUAGE sql STABLE;

CREATE OR REPLACE FUNCTION {schema}.is_admin()
RETURNS BOOLEAN AS $$
  SELECT 'tier-owner' = ANY({schema}.user_groups())
$$ LANGUAGE sql STABLE;

6.2 Update RLS Policies

-- Example: Update existing RLS policy
DROP POLICY IF EXISTS "Users can view own items" ON {schema}.items;

CREATE POLICY "Users can view own items" ON {schema}.items
  FOR SELECT
  USING (
    {schema}.is_admin()
    OR user_id = {schema}.auth_user_id()
  );

Phase 7: Testing

7.1 Test AUTH_MODE=none

AUTH_MODE=none npm run dev

Verify: - [ ] All routes accessible without login - [ ] No auth errors in console - [ ] Can navigate entire app

7.2 Test AUTH_MODE=mock

AUTH_MODE=mock npm run dev

Verify: - [ ] Dev banner shows "Authentication mocked" - [ ] Protected routes accessible - [ ] User info shows mock user - [ ] API routes return data - [ ] RBAC works with mock groups

7.3 Test AUTH_MODE=mock with Different Roles

AUTH_MODE=mock MOCK_USER_GROUPS=tier-guests npm run dev

Verify: - [ ] Reduced permissions applied - [ ] Admin features hidden/inaccessible

7.4 Test AUTH_MODE=authentik

AUTH_MODE=authentik npm run dev

Verify: - [ ] Redirect to Authentik login - [ ] OAuth flow completes - [ ] Session established - [ ] User info correct - [ ] Groups populated - [ ] Logout works - [ ] RLS filters data correctly

7.5 Test Production Build

AUTH_MODE=authentik npm run build

Verify: - [ ] Build succeeds with AUTH_MODE=authentik - [ ] Build fails with AUTH_MODE=mock (safeguard works)

Phase 8: Deployment

8.1 Update Kubernetes Secrets

Add to deployment secrets:

AUTH_MODE: authentik
AUTH_SECRET: <base64-encoded-secret>
AUTH_URL: https://{app}.internal
AUTHENTIK_ISSUER: https://auth.internal/application/o/{app-name}/
AUTHENTIK_CLIENT_ID: <your-client-id>
AUTHENTIK_CLIENT_SECRET: <your-client-secret>

8.2 Update CI/CD

Add auth validation to GitHub Actions:

- name: Validate auth configuration
  run: |
    if [[ "$AUTH_MODE" != "authentik" ]]; then
      echo "ERROR: Deployment requires AUTH_MODE=authentik"
      exit 1
    fi
  env:
    AUTH_MODE: ${{ secrets.AUTH_MODE }}

8.3 Deploy

# Using deployment script
/root/tower-fleet/scripts/deploy-{app}.sh

Migration Checklist

Authentik Setup

  • [ ] Created OAuth2/OpenID Provider
  • [ ] Created Application
  • [ ] Configured redirect URIs (localhost + production)
  • [ ] Recorded client ID and secret

Dependencies

  • [ ] Installed next-auth@beta
  • [ ] Installed jsonwebtoken
  • [ ] Installed zod

File Structure

  • [ ] Created src/lib/auth/ directory
  • [ ] Added index.ts, config.ts, types.ts
  • [ ] Added middleware.ts, supabase-bridge.ts, rbac.ts
  • [ ] Added providers/authentik.ts, mock.ts, none.ts
  • [ ] Created src/components/auth/ components
  • [ ] Added src/types/next-auth.d.ts
  • [ ] Created API routes

Code Updates

  • [ ] Updated root middleware
  • [ ] Updated root layout with SessionProvider
  • [ ] Added DevAuthBanner
  • [ ] Updated next.config.ts with production check
  • [ ] Migrated API routes to new auth pattern
  • [ ] Migrated client components to useSession
  • [ ] Migrated server components to auth()

Environment

  • [ ] Updated .env.local with all auth vars
  • [ ] Generated AUTH_SECRET

Database

  • [ ] Created RLS helper functions (if applicable)
  • [ ] Updated RLS policies (if applicable)

Testing

  • [ ] Tested AUTH_MODE=none
  • [ ] Tested AUTH_MODE=mock
  • [ ] Tested AUTH_MODE=mock with different roles
  • [ ] Tested AUTH_MODE=authentik
  • [ ] Verified production build safeguard

Deployment

  • [ ] Updated Kubernetes secrets
  • [ ] Updated CI/CD pipeline
  • [ ] Deployed successfully

Troubleshooting

"Invalid issuer" Error

  • Verify AUTHENTIK_ISSUER ends with /
  • Check Authentik provider is properly configured
  • Ensure provider slug matches issuer URL

OAuth Callback Fails

  • Verify redirect URI in Authentik matches exactly
  • Check AUTH_URL matches actual URL
  • Ensure AUTH_TRUST_HOST=true for proxied setups

RLS Not Working

  • Verify SUPABASE_JWT_SECRET matches Supabase config
  • Check JWT claims include expected fields
  • Test with SELECT current_setting('request.jwt.claims', true)

Mock Mode Not Working

  • Verify AUTH_MODE is set correctly
  • Check SessionProvider wraps the app
  • Verify mock user defaults in types.ts

Rollback Plan

If migration fails:

  1. Revert to previous auth implementation
  2. Remove new auth dependencies
  3. Restore original middleware
  4. Restore original API routes
  5. Keep Authentik application (can retry later)