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.
npm install @supabase/ssr @supabase/supabase-jsCrear el cliente para el servidor
// 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
// 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.
// 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:
- Leer: Para obtener la sesión actual del usuario
- 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:
// 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:
// 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:
// 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:
// 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
// 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
'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:
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- Middleware: Redirige usuarios no autenticados antes de renderizar nada
- Server Component: Verifica la sesión y obtiene datos del usuario
- 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:
// 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 = nextVerificar 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
| Contexto | Cliente de Supabase | Método de auth |
|---|---|---|
| Middleware | createServerClient (de @supabase/ssr) | getUser() |
| Server Component | createClient (de lib/supabase/server) | getUser() |
| API Route | createClient (de lib/supabase/server) | getUser() |
| Client Component | createClient (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.