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:
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)¶
- Check if GoTrue supports custom OIDC issuers
- Find exact env var needed
- Test with curl if possible
Phase 2: POC Token Exchange (1-2 hours)¶
- Add
auth-bridge.tsto home-portal - Test
signInWithIdToken()call - Verify Supabase user created
Phase 3: Enable RLS (1 hour)¶
- Create migration
- Update one API route
- Test data isolation
Phase 4: Roll Out (30 min per app)¶
- Update remaining API routes
- Remove service role usage (keep for admin only)
- Apply to other apps
Open Questions¶
- Exact GoTrue env var? - Need to verify
GOTRUE_EXTERNAL_ALLOW_ID_TOKEN_ISSUERSor similar - User ID mapping? - Will
auth.uid()match Authentik'ssubclaim? - 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.