Skip to content

Authentik + Supabase Auth Federation (Target Architecture)

Status: Proposed Effort: Low-Medium Benefit: Database-enforced security (RLS) + SSO

Overview

Federate Authentik with Supabase Auth using token exchange: 1. User authenticates via Authentik (NextAuth - already working) 2. App exchanges Authentik token for Supabase session 3. RLS policies enforce data access automatically

┌─────────────────────────────────────────────────────────────────┐
│                         Current                                  │
├─────────────────────────────────────────────────────────────────┤
│  User → NextAuth → Authentik → App → .eq('user_id') → Postgres  │
│                                            ↑                     │
│                              Manual filtering (can forget)       │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                         Target                                   │
├─────────────────────────────────────────────────────────────────┤
│  User → NextAuth → Authentik → Token Exchange → Supabase Auth   │
│                                      ↓                           │
│                                 RLS → Postgres                   │
│                                  ↑                               │
│                        Database enforced (can't forget)          │
└─────────────────────────────────────────────────────────────────┘

Why Token Exchange

Approach Proxy Needed NextAuth Complexity
OAuth Redirect (Traefik) Yes No High
Token Exchange No Yes (keep) Low

We already have NextAuth working with Authentik. Just add one API call to exchange the token.


How It Works

1. User clicks "Login"
2. NextAuth redirects to Authentik
3. User authenticates
4. NextAuth receives Authentik ID token
5. App calls: supabase.auth.signInWithIdToken({ token })
6. Supabase Auth validates token, creates/links user
7. Supabase issues its own JWT
8. RLS policies use auth.uid() automatically

Implementation

Step 1: Configure GoTrue to Trust Authentik

Add env var to GoTrue deployment:

kubectl patch deployment gotrue -n supabase --type='json' -p='[
  {"op": "add", "path": "/spec/template/spec/containers/0/env/-",
   "value": {"name": "GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED", "value": "false"}},
  {"op": "add", "path": "/spec/template/spec/containers/0/env/-",
   "value": {"name": "GOTRUE_THIRD_PARTY_AUTH_ENABLED", "value": "true"}}
]'

Note: We need to verify the exact env var for allowing custom issuers. May need:

GOTRUE_EXTERNAL_ALLOW_ID_TOKEN_ISSUERS: "http://auth.internal/application/o/home-portal/"

Step 2: Ensure Authentik Uses RS256

In Authentik, the OAuth provider must use asymmetric signing (RS256), not HS256.

Check: http://auth.internal/application/o/home-portal/.well-known/openid-configuration

Look for: "id_token_signing_alg_values_supported": ["RS256"]

Step 3: Add Token Exchange to App

After NextAuth login, exchange the token with Supabase:

// lib/supabase/auth-bridge.ts
import { auth } from '@/lib/auth'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

export async function getSupabaseSession() {
  const session = await auth()

  if (!session?.accessToken) {
    return null
  }

  // Exchange Authentik token for Supabase session
  const { data, error } = await supabase.auth.signInWithIdToken({
    provider: 'oidc',
    token: session.accessToken,
  })

  if (error) {
    console.error('Token exchange failed:', error)
    return null
  }

  return data.session
}

Step 4: Use in API Routes

// app/api/services/route.ts
import { getSupabaseSession } from '@/lib/supabase/auth-bridge'
import { createClient } from '@supabase/supabase-js'

export async function GET() {
  const session = await getSupabaseSession()

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

  // Create client with user's session
  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      global: {
        headers: {
          Authorization: `Bearer ${session.access_token}`
        }
      }
    }
  )

  // RLS filters automatically - no .eq('user_id') needed!
  const { data, error } = await supabase
    .from('services')
    .select('*')

  if (error) {
    return Response.json({ error: error.message }, { status: 500 })
  }

  return Response.json(data)
}

Step 5: Re-enable RLS

-- Migration: re-enable RLS

-- Enable RLS on tables
ALTER TABLE home_portal.services ENABLE ROW LEVEL SECURITY;

-- Create policy using Supabase auth.uid()
CREATE POLICY "users_own_data" ON home_portal.services
  FOR ALL USING (auth.uid()::text = user_id);

What Changes

Code Changes

File Change
lib/supabase/auth-bridge.ts New - Token exchange helper
API routes Use getSupabaseSession() instead of getAuthContext()
lib/supabase/authenticated.ts Remove or update

No Changes Needed

Component Why
NextAuth config Keep as-is
Login page Keep as-is
Middleware Keep as-is (NextAuth still protects routes)
Authentik provider Already configured

Infrastructure Changes

Component Change
GoTrue Add env var to trust Authentik issuer

Migration Plan

Phase 1: Validate GoTrue Config (30 min)

  1. Check if GoTrue supports custom OIDC issuers
  2. Find exact env var needed
  3. Test with curl if possible

Phase 2: POC Token Exchange (1-2 hours)

  1. Add auth-bridge.ts to home-portal
  2. Test signInWithIdToken() call
  3. Verify Supabase user created

Phase 3: Enable RLS (1 hour)

  1. Create migration
  2. Update one API route
  3. Test data isolation

Phase 4: Roll Out (30 min per app)

  1. Update remaining API routes
  2. Remove service role usage (keep for admin only)
  3. Apply to other apps

Open Questions

  1. Exact GoTrue env var? - Need to verify GOTRUE_EXTERNAL_ALLOW_ID_TOKEN_ISSUERS or similar
  2. User ID mapping? - Will auth.uid() match Authentik's sub claim?
  3. Token refresh? - When Authentik token expires, need to re-exchange

Fallback

If token exchange doesn't work: 1. Keep current architecture (NextAuth + manual filtering) 2. Add CI check for missing .eq('user_id') 3. Branch protection to enforce


Summary

Before After
NextAuth → Authentik → Manual .eq() NextAuth → Authentik → Token Exchange → RLS
Can forget filter Can't forget (database enforced)
Service role everywhere Anon key + user session

One function call (signInWithIdToken) bridges NextAuth and Supabase Auth.