Building a Fine-Grained Access Control System with Next.js and Supabase
- Published on
- ...
- Authors

- Name
- Huashan
- @herohuashan
Overview
When building my personal blog and health tracking application, I needed a secure yet flexible access control system. After technical evaluation and implementation, I built a complete permission management solution based on Next.js 15 and Supabase Auth with the following features:
- 🔐 Multiple Authentication Methods: Email Magic Link (passwordless), GitHub OAuth, Google OAuth
- 🛡️ Middleware Route Protection: Using Next.js middleware to intercept unauthorized access
- 👥 Allowlist Mechanism: User authorization table based on Supabase database
- 🎯 Fine-Grained Permission Control: Four-level access permissions (Public/Family/Work/Private)
- ⚡ Performance Optimization: Cookie caching to reduce database queries
- 🚀 Developer Experience: Full-stack type safety with TypeScript
In this article, I'll detail the architecture design and implementation of the entire system.
System Architecture
Overall Flow
┌─────────────────────────────────────────────────────────────────┐
│ User Access Flow │
└─────────────────────────────────────────────────────────────────┘
1. User accesses /tracking or protected blog posts
↓
2. Next.js Middleware intercepts the request
↓
3. Check if user is logged in (Supabase Session)
↓
┌──────────┴──────────┐
│ Not logged in │ Logged in
↓ ↓
Redirect to /login Check cache
↓
┌─────────┴─────────┐
│ Cache hit │ Cache miss
↓ ↓
Allow access Query allowed_users table
↓
┌─────────┴─────────┐
│ In allowlist │ Not in allowlist
↓ ↓
Check access level Redirect to /unauthorized
↓
┌──────────┴──────────┐
│ Sufficient perms │ Insufficient perms
↓ ↓
Allow access Show insufficient permissions
Tech Stack
| Layer | Technology | Purpose |
|---|---|---|
| Frontend Framework | Next.js 15 (App Router) | Server-side rendering, routing, middleware |
| Authentication | Supabase Auth | User authentication, Session management |
| Database | Supabase (PostgreSQL) | Store allowlist, access levels |
| UI Components | Tailwind CSS | Responsive login pages |
| Type Safety | TypeScript | Full-stack type definitions |
Core Implementation
1. Authentication Layer: Multiple Login Methods
1.1 Login Page Implementation
I implemented three authentication methods to meet different user needs:
File: app/login/page.tsx
'use client'
import { useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { createClient } from '@/lib/supabase-auth'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [emailSent, setEmailSent] = useState(false)
const searchParams = useSearchParams()
const redirect = searchParams.get('redirect') || '/'
const supabase = createClient()
// Method 1: Email Magic Link (Passwordless)
const handleEmailLogin = async () => {
setLoading(true)
const origin = window.location.origin
const { error } = await supabase.auth.signInWithOtp({
email: email,
options: {
emailRedirectTo: `${origin}/auth/callback?redirect=${encodeURIComponent(redirect)}`,
},
})
if (error) {
alert('Failed to send: ' + error.message)
} else {
setEmailSent(true)
}
setLoading(false)
}
// Method 2: GitHub OAuth
const handleGithubLogin = async () => {
const origin = window.location.origin
await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: `${origin}/auth/callback?redirect=${encodeURIComponent(redirect)}`,
},
})
}
// Method 3: Google OAuth
const handleGoogleLogin = async () => {
const origin = window.location.origin
await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${origin}/auth/callback?redirect=${encodeURIComponent(redirect)}`,
},
})
}
return (
<div className="flex min-h-screen items-center justify-center">
{/* Login form UI */}
</div>
)
}
Comparison of Three Authentication Methods:
| Method | Pros | Cons | Use Cases |
|---|---|---|---|
| Email Magic Link | No password, secure, simple | Requires email access | Personal blogs, lightweight apps |
| GitHub OAuth | Fast, developer-friendly | GitHub users only | Tech blogs, open source projects |
| Google OAuth | Large user base, high trust | Requires OAuth configuration | General applications |
1.2 Authentication Callback Handling
File: app/auth/callback/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createServerClient } from '@supabase/ssr'
export async function GET(request: NextRequest) {
const requestUrl = new URL(request.url)
const code = requestUrl.searchParams.get('code')
const redirect = requestUrl.searchParams.get('redirect') || '/'
if (code) {
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
)
},
},
}
)
// Exchange code for session
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (error) {
return NextResponse.redirect(new URL('/login?error=auth_failed', request.url))
}
// Redirect to original requested page
return NextResponse.redirect(new URL(redirect, request.url))
}
return NextResponse.redirect(new URL('/login?error=no_code', request.url))
}
Key Points:
- Use
exchangeCodeForSessionto exchange authorization code for session - Maintain session security through Server-Side Cookie management
- Support
redirectparameter to return users to their original destination
2. Authorization Layer: Middleware + Allowlist
2.1 Middleware Route Protection
File: middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { createServerClient } from '@supabase/ssr'
// Paths that require protection
const PROTECTED_PATHS = ['/tracking']
export async function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname
let response = NextResponse.next()
// Check if it's a protected path
const isProtectedPath = PROTECTED_PATHS.some((path) => pathname.startsWith(path))
if (isProtectedPath) {
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
)
},
},
}
)
// Get user session
const { data: { session } } = await supabase.auth.getSession()
// Not logged in → Redirect to login
if (!session) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('redirect', pathname)
return NextResponse.redirect(loginUrl)
}
// ⚡ Performance optimization: Check cached allowlist status
const cachedAllowlist = request.cookies.get('auth_allowed')?.value
const userEmail = session.user.email
// Cache hit → Skip database query
if (cachedAllowlist === userEmail) {
return response
}
// Query allowlist table
const { data: allowedUser } = await supabase
.from('allowed_users')
.select('email')
.eq('email', userEmail)
.maybeSingle()
if (!allowedUser) {
response.cookies.delete('auth_allowed')
return NextResponse.redirect(new URL('/unauthorized', request.url))
}
// Cache verification result (5 minutes)
response.cookies.set('auth_allowed', userEmail!, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 5, // 5 minutes
})
}
return response
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|static).*)'],
}
Middleware Advantages:
- ✅ Early Interception: Executes before request reaches page components
- ✅ Centralized Management: All route protection logic in one place
- ✅ Performance Optimization: Supports caching to reduce database queries
- ✅ Flexible Configuration: Easy management through
PROTECTED_PATHSarray
2.2 Database Allowlist Table
Table Structure:
CREATE TABLE allowed_users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
access_level TEXT DEFAULT 'public'
CHECK (access_level IN ('public', 'family', 'work', 'private')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Index optimization
CREATE INDEX idx_allowed_users_email ON allowed_users(email);
Access Level Description:
| Level | Permission Scope | Use Cases |
|---|---|---|
| public | Public content only | Regular visitors, trial users |
| family | Public + Family content | Family members, close friends |
| work | Public + Work content | Colleagues, business partners |
| private | All content | Blog owner |
3. Fine-Grained Permission Control
3.1 Blog Post Access Control
File: app/blog/[...slug]/page.tsx
import { notFound, redirect } from 'next/navigation'
import { allBlogs } from 'contentlayer/generated'
import { createServerComponentClient } from '@/lib/supabase-auth'
type AccessLevel = 'public' | 'family' | 'work' | 'private'
// Permission level mapping
const ACCESS_HIERARCHY: Record<AccessLevel, number> = {
public: 0,
family: 1,
work: 1,
private: 2,
}
export default async function BlogPostPage({
params,
}: {
params: { slug: string[] }
}) {
const post = allBlogs.find(/* ... */)
if (!post) notFound()
// Get article access level
const contentAccessLevel = (post as any).accessLevel as AccessLevel || 'public'
// If article requires permission control
if (contentAccessLevel !== 'public' || post.private) {
const supabase = await createServerComponentClient()
const { data: { session } } = await supabase.auth.getSession()
// Not logged in → Redirect to login
if (!session) {
redirect(`/login?redirect=/blog/${params.slug.join('/')}`)
}
// Query user access level
const { data: allowedUser } = await supabase
.from('allowed_users')
.select('email, access_level')
.eq('email', session.user.email)
.single()
if (!allowedUser) {
redirect('/unauthorized')
}
const userAccessLevel = allowedUser.access_level as AccessLevel
// Check if permissions are sufficient
if (ACCESS_HIERARCHY[userAccessLevel] < ACCESS_HIERARCHY[contentAccessLevel]) {
return (
<div className="container py-12">
<div className="rounded-lg border-2 border-yellow-400 bg-yellow-50 p-6">
<h2 className="text-xl font-bold">🔒 Insufficient Permissions</h2>
<p className="mt-2 text-gray-700">
This article requires <strong>{contentAccessLevel}</strong> level access,
but your current level is <strong>{userAccessLevel}</strong>.
</p>
</div>
</div>
)
}
}
// Render article content
return <BlogLayout post={post} />
}
3.2 Setting Access Levels in MDX Files
---
title: 'My Private Blog Post'
date: '2025-11-21'
tags: ['Personal']
accessLevel: 'private' # Set access level
---
This is a post that only I can see...
4. Performance Optimization
4.1 Middleware Caching Mechanism
Before Optimization: Query database on every access
Request 1 → Query DB (150ms)
Request 2 → Query DB (150ms)
Request 3 → Query DB (150ms)
Total: 450ms
After Optimization: Cookie cache (5 minutes)
Request 1 → Query DB (150ms) → Cache
Request 2 → Read cache (1ms)
Request 3 → Read cache (1ms)
Total: 152ms (↓66%)
4.2 Database Indexes
File: scripts/add_performance_indexes.sql
-- Add index for allowed_users.email
CREATE INDEX IF NOT EXISTS idx_allowed_users_email
ON allowed_users(email);
-- Analyze table to update query optimizer statistics
ANALYZE allowed_users;
Effect: Allowlist query speed improved by 80-90%
Security Considerations
1. Session Security
- ✅ Use HttpOnly Cookie to store session, preventing XSS attacks
- ✅ Set SameSite=lax to prevent CSRF attacks
- ✅ Force Secure flag in production, HTTPS only
2. SQL Injection Protection
- ✅ Use parameterized queries from Supabase SDK
- ✅ All user input is validated and escaped
3. Permission Checks
- ✅ Double Verification: Both Middleware and page components check permissions
- ✅ Principle of Least Privilege: Default to
publiclevel - ✅ Server-Side Validation: All permission checks executed on server
Deployment Configuration
1. Environment Variables
.env.local:
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGc...
# Optional: Production configuration
NODE_ENV=production
2. Supabase Configuration
2.1 Enable Authentication Providers
In Supabase Dashboard → Authentication → Providers:
- ✅ Email (Magic Link)
- ✅ GitHub OAuth
- ✅ Google OAuth
2.2 Configure Callback URL
https://your-domain.com/auth/callback
2.3 Initialize Allowlist Table
-- Add your email to allowlist
INSERT INTO allowed_users (email, access_level)
VALUES ('[email protected]', 'private');
-- Add family member email
INSERT INTO allowed_users (email, access_level)
VALUES ('[email protected]', 'family');
3. Next.js Deployment
Supports deployment to:
- ✅ Vercel
- ✅ Cloudflare Pages
- ✅ Self-hosted Node.js
Best Practices
1. Layered Design
┌─────────────────────┐
│ UI Layer │ Login forms, error messages
├─────────────────────┤
│ Auth Layer │ Supabase Auth
├─────────────────────┤
│ Middleware │ Route protection, caching
├─────────────────────┤
│ Access Control │ Fine-grained permission checks
├─────────────────────┤
│ Database │ Allowlist, access levels
└─────────────────────┘
2. Error Handling
// Friendly error messages
if (!allowedUser) {
return (
<div className="container py-12">
<h2>⛔ Access Denied</h2>
<p>Your account is not authorized to access this content.</p>
<p>Please contact the administrator for access.</p>
<button onClick={() => signOut()}>Sign Out</button>
</div>
)
}
3. Performance Monitoring
// Add performance monitoring
console.time('auth-check')
const { data: allowedUser } = await supabase.from('allowed_users')...
console.timeEnd('auth-check')
// Monitor cache hit rate
const cacheHit = cachedAllowlist === userEmail
analytics.track('auth_cache', { hit: cacheHit })
Summary
With this access control system, I achieved:
- 🔐 Security: Multi-layer protection, Session management, SQL injection prevention
- ⚡ High Performance: Cache optimization, database indexes, 66% reduction in query time
- 🎯 Flexibility: Four-level permissions, article-level control support
- 👥 Usability: Three login methods, friendly error messages
- 🚀 Scalability: Clear architecture, easy to add new features
Full Code
All code is open source, see my GitHub repository: blog-nextjs
Related Resources
- Next.js Middleware Documentation
- Supabase Auth Documentation
- Next.js App Router Authentication Best Practices
If you found this article helpful, please share and discuss!
Tags: #Next.js #Supabase #Access-Control #TypeScript #Full-Stack-Development