skills/dadbodgeoff/drift/middleware-protection

middleware-protection

SKILL.md

Middleware Route Protection

Check auth once, protect routes declaratively.

When to Use This Skill

  • Need to protect multiple routes
  • Want centralized auth checking
  • Tired of repeating auth logic in every route
  • Need role-based access control

Core Concepts

  1. Middleware intercepts - All requests pass through middleware
  2. Declarative routes - Define protected/public routes in config
  3. Session refresh - Keep sessions alive automatically
  4. Consistent errors - API routes get JSON, pages get redirects

TypeScript Implementation

middleware.ts

// middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';

// Routes that require authentication
const PROTECTED_ROUTES = [
  '/dashboard',
  '/settings',
  '/api/user',
  '/api/predictions',
];

// Routes that are always public
const PUBLIC_ROUTES = [
  '/',
  '/login',
  '/signup',
  '/api/health',
  '/api/public',
];

export async function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  // Skip static files and Next.js internals
  if (
    pathname.startsWith('/_next') ||
    pathname.startsWith('/favicon') ||
    pathname.match(/\.(svg|png|jpg|jpeg|gif|webp|ico)$/)
  ) {
    return NextResponse.next();
  }

  // Skip explicitly public routes
  if (PUBLIC_ROUTES.some(route => pathname === route || pathname.startsWith(route + '/'))) {
    return NextResponse.next();
  }

  // Create response that we'll modify
  let response = NextResponse.next({ request });

  // Create Supabase client with cookie handling
  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 }) =>
            request.cookies.set(name, value)
          );
          response = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  // Refresh session (important for SSR)
  const { data: { user } } = await supabase.auth.getUser();

  // Check if route requires auth
  const requiresAuth = PROTECTED_ROUTES.some(route => 
    pathname === route || pathname.startsWith(route + '/')
  );

  if (requiresAuth && !user) {
    // API routes: return 401 JSON
    if (pathname.startsWith('/api/')) {
      return NextResponse.json(
        {
          error: 'Authentication required',
          code: 'AUTH_REQUIRED',
          loginUrl: '/login',
        },
        { status: 401 }
      );
    }
    
    // Pages: redirect to login with return URL
    const url = request.nextUrl.clone();
    url.pathname = '/login';
    url.searchParams.set('redirectTo', pathname);
    return NextResponse.redirect(url);
  }

  // Add user ID to headers for downstream use
  if (user) {
    response.headers.set('x-user-id', user.id);
  }

  return response;
}

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

Using User ID in API Routes

// app/api/user/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createServerSupabaseClient } from '@/lib/supabase-server';

export async function GET(request: NextRequest) {
  // User ID was added by middleware
  const userId = request.headers.get('x-user-id');
  
  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const supabase = await createServerSupabaseClient();
  
  const { data: profile } = await supabase
    .from('user_profiles')
    .select('*')
    .eq('id', userId)
    .single();

  return NextResponse.json({ profile });
}

Role-Based Protection

// middleware.ts (extended)

const ROUTE_ROLES: Record<string, string[]> = {
  '/admin': ['admin'],
  '/dashboard': ['user', 'admin'],
  '/api/admin': ['admin'],
};

// After getting user, check role
if (user) {
  const userRole = user.user_metadata?.role || 'user';
  
  const requiredRoles = Object.entries(ROUTE_ROLES)
    .find(([route]) => pathname.startsWith(route))?.[1];

  if (requiredRoles && !requiredRoles.includes(userRole)) {
    if (pathname.startsWith('/api/')) {
      return NextResponse.json(
        { error: 'Forbidden', code: 'FORBIDDEN' },
        { status: 403 }
      );
    }
    return NextResponse.redirect(new URL('/unauthorized', request.url));
  }
}

Pattern-Based Routes

// For more complex route matching
const PROTECTED_PATTERNS = [
  /^\/dashboard(\/.*)?$/,
  /^\/api\/user\/.*$/,
  /^\/settings$/,
  /^\/api\/v\d+\/private\/.*/,  // /api/v1/private/*, /api/v2/private/*
];

const requiresAuth = PROTECTED_PATTERNS.some(pattern => 
  pattern.test(pathname)
);

Python Implementation (FastAPI)

# middleware/auth.py
from fastapi import Request, HTTPException
from fastapi.responses import RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
from typing import Set

PROTECTED_ROUTES: Set[str] = {
    "/dashboard",
    "/settings",
    "/api/user",
}

PUBLIC_ROUTES: Set[str] = {
    "/",
    "/login",
    "/signup",
    "/api/health",
}


class AuthMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        path = request.url.path
        
        # Skip public routes
        if path in PUBLIC_ROUTES or any(path.startswith(r + "/") for r in PUBLIC_ROUTES):
            return await call_next(request)
        
        # Check if route requires auth
        requires_auth = path in PROTECTED_ROUTES or any(
            path.startswith(r + "/") for r in PROTECTED_ROUTES
        )
        
        if not requires_auth:
            return await call_next(request)
        
        # Get user from session/token
        user = await get_user_from_request(request)
        
        if not user:
            if path.startswith("/api/"):
                raise HTTPException(
                    status_code=401,
                    detail={
                        "error": "Authentication required",
                        "code": "AUTH_REQUIRED",
                        "login_url": "/login",
                    }
                )
            return RedirectResponse(f"/login?redirectTo={path}")
        
        # Add user to request state
        request.state.user = user
        request.state.user_id = user.id
        
        return await call_next(request)


async def get_user_from_request(request: Request):
    """Extract and validate user from request."""
    token = request.cookies.get("session") or request.headers.get("Authorization")
    if not token:
        return None
    # Validate token and return user
    return await validate_session(token)
# Using in routes
from fastapi import Request, Depends

@app.get("/api/user/profile")
async def get_profile(request: Request):
    user_id = request.state.user_id
    profile = await db.get_profile(user_id)
    return {"profile": profile}

Error Response Format

// Consistent error format for API routes
interface AuthError {
  error: string;
  code: 'AUTH_REQUIRED' | 'SESSION_EXPIRED' | 'FORBIDDEN';
  message?: string;
  loginUrl: string;
}

// 401 - Not authenticated
{
  "error": "Authentication required",
  "code": "AUTH_REQUIRED",
  "loginUrl": "/login"
}

// 403 - Authenticated but not authorized
{
  "error": "Forbidden",
  "code": "FORBIDDEN",
  "message": "Admin access required"
}

Best Practices

  1. Refresh sessions - Call getUser() in middleware to refresh
  2. Pass user ID - Add to headers for downstream routes
  3. JSON for APIs - Never redirect API routes
  4. Return URL - Include redirectTo param for login redirects
  5. Skip static - Don't process static files

Common Mistakes

  • Redirecting API routes (should return 401 JSON)
  • Not refreshing session in middleware
  • Processing static files through auth
  • Missing return URL on login redirect
  • Not handling role-based access

Related Skills

Weekly Installs
22
GitHub Stars
761
First Seen
Jan 25, 2026
Installed on
codex22
opencode21
github-copilot21
gemini-cli20
cursor19
amp17