Proteger Rutas con Supabase Auth

Tener autenticación implementada no sirve de nada si cualquier usuario puede acceder a las rutas protegidas. En esta sección vas a aprender como verificar el estado de auth en cada capa de tu app con NextJS: middleware, Server Components, API routes y componentes del cliente.

El paquete @supabase/ssr

En el servidor no existe localStorage, así que el SDK estándar de Supabase no puede manejar sesiones. Para esto existe @supabase/ssr, un paquete que almacena la sesión en cookies HTTP.

bash
npm install @supabase/ssr @supabase/supabase-js

Crear el cliente para el servidor

typescript
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
 
export async function createClient() {
  const cookieStore = await cookies()
 
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // setAll puede fallar en Server Components (son read-only).
            // Esto es esperado y no es un error.
          }
        }
      }
    }
  )
}

Crear el cliente para el browser

typescript
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
 
export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}
Dos clientes, un propósito

El cliente de servidor usa cookies para la sesión. El cliente del browser usa cookies también (a través de @supabase/ssr), reemplazando el comportamiento default de localStorage. Esto garantiza que ambos lados lean la misma sesión.

Middleware: la primera línea de defensa

El middleware (código que se ejecuta antes de que NextJS procese la petición) es el lugar ideal para verificar la autenticación. Se ejecuta en cada request, antes de llegar a cualquier Server Component o API route.

typescript
// middleware.ts (en la raiz del proyecto)
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
 
export async function middleware(request: NextRequest) {
  let supabaseResponse = NextResponse.next({
    request
  })
 
  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 }) =>
            request.cookies.set(name, value)
          )
          supabaseResponse = NextResponse.next({
            request
          })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
        }
      }
    }
  )
 
  // IMPORTANTE: No uses getSession() aqui.
  // getUser() valida el token contra el servidor de Supabase.
  const { data: { user } } = await supabase.auth.getUser()
 
  // Rutas que requieren autenticacion
  const protectedRoutes = ['/dashboard', '/settings', '/profile']
  const isProtectedRoute = protectedRoutes.some(route =>
    request.nextUrl.pathname.startsWith(route)
  )
 
  if (isProtectedRoute && !user) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('next', request.nextUrl.pathname)
    return NextResponse.redirect(loginUrl)
  }
 
  // Si el usuario ya esta autenticado y visita /login, redirigir al dashboard
  const authRoutes = ['/login', '/register']
  const isAuthRoute = authRoutes.some(route =>
    request.nextUrl.pathname.startsWith(route)
  )
 
  if (isAuthRoute && user) {
    return NextResponse.redirect(new URL('/dashboard', request.url))
  }
 
  return supabaseResponse
}
 
export const config = {
  matcher: [
    // Ejecutar en todas las rutas excepto archivos estaticos y APIs internas
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'
  ]
}
Usa getUser, no getSession en el middleware

getSession() lee la sesión de las cookies sin validarla contra el servidor. Un usuario malintencionado podría manipular las cookies. getUser() hace una petición al servidor de Supabase para confirmar que la sesión es legitima. En el middleware, siempre usa getUser().

Por qué el middleware maneja cookies

El middleware necesita leer y escribir cookies por dos razones:

  1. Leer: Para obtener la sesión actual del usuario
  2. Escribir: Para renovar el token si está por expirar. Cuando getUser() detecta que el access_token vencio, usa el refresh_token para obtener uno nuevo y lo guarda en las cookies

Si no propagas las cookies correctamente, la sesión se pierde en cada request.

Obtener el usuario en Server Components

Dentro de un Server Component, puedes verificar si el usuario está autenticado:

typescript
// app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
 
export default async function DashboardPage() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
 
  if (!user) {
    redirect('/login')
  }
 
  return (
    <div>
      <h1>Dashboard</h1>
      <p>Bienvenido, {user.email}</p>
      <p>ID: {user.id}</p>
    </div>
  )
}
Doble verificación

Si ya tienes middleware protegiendo la ruta, la verificación en el Server Component es redundante pero recomendada. El middleware puede fallar por un error de configuración, y la verificación en el componente actua como segunda capa de seguridad. defense in depth (defensa en profundidad).

Obtener datos del usuario junto con datos de la app

Un patrón común es obtener el usuario y sus datos de la base de datos en paralelo:

typescript
// app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
 
export default async function DashboardPage() {
  const supabase = await createClient()
 
  const { data: { user } } = await supabase.auth.getUser()
 
  if (!user) {
    redirect('/login')
  }
 
  // Obtener datos adicionales del usuario desde tu tabla de perfiles
  const { data: profile } = await supabase
    .from('profiles')
    .select('nombre, avatar_url, plan')
    .eq('id', user.id)
    .single()
 
  return (
    <div>
      <h1>Hola, {profile?.nombre ?? user.email}</h1>
      <p>Plan: {profile?.plan ?? 'free'}</p>
    </div>
  )
}

Proteger API Routes

Las API routes (route handlers en App Router) también necesitan verificar autenticación:

typescript
// app/api/user/profile/route.ts
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
 
export async function GET() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
 
  if (!user) {
    return NextResponse.json(
      { error: 'No autenticado' },
      { status: 401 }
    )
  }
 
  const { data: profile, error } = await supabase
    .from('profiles')
    .select('*')
    .eq('id', user.id)
    .single()
 
  if (error) {
    return NextResponse.json(
      { error: 'Error obteniendo perfil' },
      { status: 500 }
    )
  }
 
  return NextResponse.json(profile)
}
 
export async function PATCH(request: Request) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
 
  if (!user) {
    return NextResponse.json(
      { error: 'No autenticado' },
      { status: 401 }
    )
  }
 
  const body = await request.json()
 
  const { data, error } = await supabase
    .from('profiles')
    .update({ nombre: body.nombre, avatar_url: body.avatar_url })
    .eq('id', user.id)
    .select()
    .single()
 
  if (error) {
    return NextResponse.json(
      { error: 'Error actualizando perfil' },
      { status: 500 }
    )
  }
 
  return NextResponse.json(data)
}

Auth context para componentes del cliente

Para componentes del cliente que necesitan saber si el usuario está autenticado (mostrar/ocultar botones, cambiar navegación, etc.), usa un contexto de React:

typescript
// providers/auth-provider.tsx
'use client'
 
import { createContext, useContext, useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import type { User } from '@supabase/supabase-js'
 
type AuthContextType = {
  user: User | null
  loading: boolean
}
 
const AuthContext = createContext<AuthContextType>({
  user: null,
  loading: true
})
 
export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(true)
  const supabase = createClient()
 
  useEffect(() => {
    // Obtener sesión inicial
    supabase.auth.getUser().then(({ data: { user } }) => {
      setUser(user)
      setLoading(false)
    })
 
    // Escuchar cambios de auth
    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (_event, session) => {
        setUser(session?.user ?? null)
        setLoading(false)
      }
    )
 
    return () => {
      subscription.unsubscribe()
    }
  }, [])
 
  return (
    <AuthContext.Provider value={{ user, loading }}>
      {children}
    </AuthContext.Provider>
  )
}
 
export function useAuth() {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth debe usarse dentro de AuthProvider')
  }
  return context
}

Usar el AuthProvider en el layout

typescript
// app/layout.tsx
import { AuthProvider } from '@/providers/auth-provider'
 
export default function RootLayout({
  children
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="es">
      <body>
        <AuthProvider>
          {children}
        </AuthProvider>
      </body>
    </html>
  )
}

Usar useAuth en componentes

typescript
'use client'
 
import { useAuth } from '@/providers/auth-provider'
import Link from 'next/link'
 
export function NavBar() {
  const { user, loading } = useAuth()
 
  if (loading) {
    return <nav className="h-16 animate-pulse bg-gray-800" />
  }
 
  return (
    <nav className="flex items-center justify-between px-6 h-16">
      <Link href="/" className="font-bold">Mi App</Link>
 
      <div>
        {user ? (
          <div className="flex items-center gap-4">
            <span className="text-sm text-gray-400">{user.email}</span>
            <LogoutButton />
          </div>
        ) : (
          <Link
            href="/login"
            className="px-4 py-2 bg-green-600 rounded-md text-sm"
          >
            Iniciar sesión
          </Link>
        )}
      </div>
    </nav>
  )
}

Patron completo: protección en capas

La autenticación robusta usa múltiples capas. Cada capa actua como respaldo si la anterior falla:

plaintext
Peticion del usuario
       |
       v
[Middleware] -- Primera verificación. Redirige si no hay sesión.
       |
       v
[Server Component] -- Segunda verificación. Obtiene datos del usuario.
       |
       v
[RLS en Supabase] -- Tercera verificación. PostgreSQL valida permisos por fila.
       |
       v
Respuesta al usuario
  1. Middleware: Redirige usuarios no autenticados antes de renderizar nada
  2. Server Component: Verifica la sesión y obtiene datos del usuario
  3. RLS: Incluso si alguien bypassea las primeras dos capas, PostgreSQL rechaza queries no autorizadas
No confies solo en el middleware

El middleware es una optimización de UX (redirigir rápido), no una medida de seguridad absoluta. La verdadera seguridad está en RLS a nivel de base de datos. Un middleware mal configurado no debe ser tu única protección.

Redirigir después del login

Un patrón común es redirigir al usuario a la página que intentaba visitar antes de ser redirigido al login:

typescript
// En el middleware, guardamos la URL original en el query param "next"
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('next', request.nextUrl.pathname)
return NextResponse.redirect(loginUrl)
 
// En el componente de login, despues de autenticar exitosamente:
const searchParams = new URLSearchParams(window.location.search)
const next = searchParams.get('next') ?? '/dashboard'
window.location.href = next

Verificar headers de seguridad

Cuando despliegas tu app a producción, es importante verificar que los headers HTTP de seguridad esten configurados correctamente. Headers como Strict-Transport-Security, X-Content-Type-Options y X-Frame-Options protegen contra ataques comunes.

Puedes auditar los headers de tu sitio con herramientas como el escaner de headers de datahogo para identificar configuraciones faltantes antes de que sean un problema.

Resumen de métodos por contexto

ContextoCliente de SupabaseMétodo de auth
MiddlewarecreateServerClient (de @supabase/ssr)getUser()
Server ComponentcreateClient (de lib/supabase/server)getUser()
API RoutecreateClient (de lib/supabase/server)getUser()
Client ComponentcreateClient (de lib/supabase/client)onAuthStateChange + getUser()

En todos los casos, getUser() es el método recomendado para verificar autenticación. getSession() solo sirve para lectura rápida de datos no criticos.