Logo

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

Published on
...
Authors

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 inLogged 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

LayerTechnologyPurpose
Frontend FrameworkNext.js 15 (App Router)Server-side rendering, routing, middleware
AuthenticationSupabase AuthUser authentication, Session management
DatabaseSupabase (PostgreSQL)Store allowlist, access levels
UI ComponentsTailwind CSSResponsive login pages
Type SafetyTypeScriptFull-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:

MethodProsConsUse Cases
Email Magic LinkNo password, secure, simpleRequires email accessPersonal blogs, lightweight apps
GitHub OAuthFast, developer-friendlyGitHub users onlyTech blogs, open source projects
Google OAuthLarge user base, high trustRequires OAuth configurationGeneral 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 exchangeCodeForSession to exchange authorization code for session
  • Maintain session security through Server-Side Cookie management
  • Support redirect parameter 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_PATHS array

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:

LevelPermission ScopeUse Cases
publicPublic content onlyRegular visitors, trial users
familyPublic + Family contentFamily members, close friends
workPublic + Work contentColleagues, business partners
privateAll contentBlog 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 1Query DB (150ms)
Request 2Query DB (150ms)
Request 3Query DB (150ms)
Total: 450ms

After Optimization: Cookie cache (5 minutes)

Request 1Query DB (150ms)Cache
Request 2Read cache (1ms)
Request 3Read 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 public level
  • 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 LayerLogin forms, error messages
├─────────────────────┤
Auth LayerSupabase Auth
├─────────────────────┤
MiddlewareRoute protection, caching
├─────────────────────┤
Access ControlFine-grained permission checks
├─────────────────────┤
DatabaseAllowlist, 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


If you found this article helpful, please share and discuss!

Tags: #Next.js #Supabase #Access-Control #TypeScript #Full-Stack-Development

Building a Fine-Grained Access Control System with Next.js and Supabase | 原子比特之间