~/blog/sso-keycloak-laravel-nextjs-openid-connect.md
SSO con Keycloak: un solo login para Laravel y Next.js con OpenID Connect
Cómo implementar inicio de sesión único (SSO) entre varios sistemas con Keycloak y OpenID Connect: despliegue en Docker, configuración del realm y los clientes, integración con Laravel y Next.js, cierre de sesión global, roles y los errores que más se repiten.
- autor
- Alejandro Peñaloza
- Publicado
- Lectura
- 5 min
- English
- Read in English →

En este artículo
Cuando una empresa crece a punta de sistemas, el usuario termina con una contraseña distinta para cada uno: el ERP, el portal de clientes, la app de reportes, el panel de administración. Cada sistema con su tabla de usuarios, su recuperación de contraseña y sus permisos. Y cada vez que alguien deja la empresa, hay que acordarse de desactivarlo en todos.
La solución es el inicio de sesión único (SSO): un solo lugar donde la gente se autentica y desde donde todos los sistemas confían en esa identidad. En este post lo implemento con Keycloak, un servidor de identidad de código abierto, conectando una aplicación Laravel y otra Next.js.
Conceptos mínimos
- OpenID Connect (OIDC): el protocolo estándar para autenticación, construido sobre OAuth 2.0. Tus aplicaciones no ven la contraseña; reciben un token firmado que dice quién es el usuario.
- Proveedor de identidad (IdP): el servidor que autentica, en este caso Keycloak.
- Realm: un espacio aislado en Keycloak con sus propios usuarios, roles y clientes. Normalmente uno por organización.
- Cliente: cada aplicación que delega el login en Keycloak.
- Flujo de código de autorización (authorization code): el flujo recomendado. La aplicación redirige al usuario a Keycloak, este vuelve con un código y la aplicación lo cambia por los tokens desde el servidor.
sequenceDiagram
participant U as Usuario
participant A as App Laravel
participant K as Keycloak
participant B as App Next.js
U->>A: Entra a la app A
A->>K: Redirige a /auth
U->>K: Usuario y contraseña (+2FA)
K-->>A: Código de autorización
A->>K: Cambia código por tokens
A-->>U: Sesión iniciada en A
U->>B: Entra a la app B
B->>K: Redirige a /auth
K-->>B: Ya hay sesión: código sin pedir contraseña
B-->>U: Sesión iniciada en B
La magia del SSO está en el segundo login: Keycloak ya tiene una sesión con el usuario, así que entrega el código a la aplicación B sin volver a pedir credenciales.
Paso 1: levantar Keycloak con Docker
Para producción, Keycloak necesita una base de datos propia y correr detrás de un proxy con HTTPS.
services:
keycloak-db:
image: postgres:16-alpine
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: ${KC_DB_PASSWORD}
volumes:
- kc-db:/var/lib/postgresql/data
restart: unless-stopped
keycloak:
image: quay.io/keycloak/keycloak:26.0
command: start
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${KC_DB_PASSWORD}
KC_HOSTNAME: https://auth.midominio.cl
KC_PROXY_HEADERS: xforwarded
KC_HTTP_ENABLED: "true"
KC_HEALTH_ENABLED: "true"
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
ports:
- "127.0.0.1:8080:8080"
depends_on:
- keycloak-db
restart: unless-stopped
volumes:
kc-db:Detrás de Nginx o Traefik con certificado para auth.midominio.cl. Puntos clave:
KC_HOSTNAMEdebe ser la URL pública exacta. Si no coincide, los tokens salen con un issuer distinto y las aplicaciones los rechazan.KC_PROXY_HEADERS: xforwardedhace que Keycloak confíe en los encabezados del proxy para saber que la conexión original era HTTPS. Asegúrate de que solo el proxy pueda llegar al puerto 8080.- La cuenta de administrador inicial es temporal: entra, crea un administrador definitivo con 2FA y elimina la inicial.
- Para desarrollo local basta
command: start-dev, sin base de datos externa.
Paso 2: configurar el realm
En la consola de administración:
- Crea un realm (por ejemplo,
empresa). No uses el realmmasterpara tus aplicaciones: es solo para administrar Keycloak. - En Realm settings → Login, decide si los usuarios pueden registrarse solos, recuperar contraseña y si el login es por correo.
- En Authentication → Policies, define la política de contraseñas y activa el OTP (o exígelo para roles administrativos).
- Configura el SMTP para que funcionen la verificación de correo y la recuperación de contraseña.
- Crea los roles de realm que comparten todos los sistemas (por ejemplo,
admin,finanzas,docente).
Paso 3: registrar las aplicaciones como clientes
Por cada aplicación, en Clients → Create client:
| Campo | Laravel | Next.js |
|---|---|---|
| Client ID | app-erp |
portal |
| Client authentication | Activado (cliente confidencial) | Activado (el login corre en el servidor) |
| Flujo | Standard flow | Standard flow |
| Valid redirect URIs | https://erp.midominio.cl/auth/callback |
https://portal.midominio.cl/api/auth/callback/keycloak |
| Valid post logout redirect URIs | https://erp.midominio.cl/ |
https://portal.midominio.cl/ |
| Web origins | https://erp.midominio.cl |
https://portal.midominio.cl |
Desactiva Direct access grants y Implicit flow si no los necesitas. Copia el client secret desde la pestaña Credentials.
Para que los roles lleguen en el token, revisa en Client scopes que el scope roles esté asignado; Keycloak incluye los roles de realm en el claim realm_access.roles del token de acceso.
Paso 4: Laravel con Socialite
composer require socialiteproviders/keycloakconfig/services.php:
'keycloak' => [
'client_id' => env('KEYCLOAK_CLIENT_ID'),
'client_secret' => env('KEYCLOAK_CLIENT_SECRET'),
'redirect' => env('KEYCLOAK_REDIRECT_URI'),
'base_url' => env('KEYCLOAK_BASE_URL'), // https://auth.midominio.cl
'realms' => env('KEYCLOAK_REALM'), // empresa
],Registra el proveedor (en Laravel 11 o superior, en el boot de AppServiceProvider):
use Illuminate\Support\Facades\Event;
use SocialiteProviders\Manager\SocialiteWasCalled;
Event::listen(function (SocialiteWasCalled $event) {
$event->extendSocialite('keycloak', \SocialiteProviders\Keycloak\Provider::class);
});Rutas y controlador:
Route::get('/login', [SsoController::class, 'redirect'])->name('login');
Route::get('/auth/callback', [SsoController::class, 'callback']);
Route::post('/logout', [SsoController::class, 'logout'])->name('logout');class SsoController extends Controller
{
public function redirect()
{
return Socialite::driver('keycloak')->scopes(['openid', 'email', 'profile'])->redirect();
}
public function callback(Request $request)
{
$sso = Socialite::driver('keycloak')->user();
$user = User::updateOrCreate(
['keycloak_sub' => $sso->getId()], // identificador estable
['name' => $sso->getName(), 'email' => $sso->getEmail()],
);
Auth::login($user);
$request->session()->regenerate();
$request->session()->put('id_token', $sso->accessTokenResponseBody['id_token'] ?? null);
return redirect()->intended('/');
}
public function logout(Request $request)
{
$idToken = $request->session()->get('id_token');
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
$url = config('services.keycloak.base_url')
. '/realms/' . config('services.keycloak.realms')
. '/protocol/openid-connect/logout?' . http_build_query([
'id_token_hint' => $idToken,
'post_logout_redirect_uri' => url('/'),
]);
return redirect()->away($url);
}
}Dos decisiones importantes:
- Vincula al usuario por
sub, el identificador inmutable de Keycloak, no por el correo (que puede cambiar). - Mantén tu tabla
userslocal. Keycloak resuelve quién es; tu aplicación sigue guardando sus propias relaciones y datos de negocio.
Paso 5: Next.js con Auth.js
npm install next-auth@beta.env.local:
AUTH_SECRET=genera-uno-con-npx-auth-secret
AUTH_KEYCLOAK_ID=portal
AUTH_KEYCLOAK_SECRET=el-client-secret
AUTH_KEYCLOAK_ISSUER=https://auth.midominio.cl/realms/empresaauth.ts:
import NextAuth from "next-auth";
import Keycloak from "next-auth/providers/keycloak";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [Keycloak],
callbacks: {
async jwt({ token, account }) {
if (account?.access_token) {
const payload = JSON.parse(
Buffer.from(account.access_token.split(".")[1], "base64url").toString(),
);
token.roles = payload.realm_access?.roles ?? [];
token.idToken = account.id_token;
}
return token;
},
async session({ session, token }) {
session.user.roles = token.roles as string[];
return session;
},
},
});app/api/auth/[...nextauth]/route.ts:
import { handlers } from "@/auth";
export const { GET, POST } = handlers;Proteger una página según rol:
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function Finanzas() {
const session = await auth();
if (!session) redirect("/api/auth/signin");
if (!session.user.roles?.includes("finanzas")) return <p>Sin acceso</p>;
return <h1>Panel de finanzas</h1>;
}Aquí el token ya fue validado por Auth.js durante el login, por eso leer el payload es seguro en ese punto. Si una API recibe tokens directamente desde el navegador, debe validar la firma contra el JWKS de Keycloak (/realms/empresa/protocol/openid-connect/certs), por ejemplo con la librería jose.
Cierre de sesión global
El SSO tiene una contraparte que se olvida: si el usuario cierra sesión en una aplicación, ¿qué pasa con las demás?
- Logout iniciado en la aplicación (RP-initiated): redirigir al endpoint de logout de Keycloak con
id_token_hint, como en el controlador de Laravel. Eso cierra la sesión en Keycloak. - Back-channel logout: en cada cliente configuras una Backchannel logout URL. Cuando la sesión de Keycloak termina, este llama a esa URL de cada aplicación con un logout token firmado, y la aplicación invalida la sesión local de ese usuario.
Sin back-channel logout, las demás aplicaciones mantienen su sesión local hasta que expire. Para sistemas internos con información sensible, impleméntalo.
Migrar usuarios que ya existen
Rara vez se parte de cero. Opciones:
- Importación masiva con la API de administración de Keycloak, enviando un correo de "define tu contraseña" en el primer ingreso.
- Federación de usuarios (LDAP o Active Directory) si ya existe un directorio.
- Vinculación por correo en el primer login: si llega un
subnuevo con un correo que existe en la tabla local y está verificado en Keycloak, se asocia y desde ahí se usa elsub.
Errores comunes
- "Invalid redirect_uri": la URL debe coincidir exactamente con la registrada, incluyendo protocolo, puerto y barra final.
- "Invalid issuer" o tokens rechazados:
KC_HOSTNAMEno coincide con la URL pública o el proxy no envíaX-Forwarded-Proto. Revisa/.well-known/openid-configurationy compara el campoissuer. - Bucle de redirecciones: cookies bloqueadas por
SameSiteo la aplicación detrás de un proxy sin confiar en los encabezados (en Laravel, configura los trusted proxies). - Tokens gigantes: con muchos roles y grupos, el token supera el límite de cookies o encabezados. Incluye solo los claims necesarios con client scopes dedicados.
- Diferencias de reloj: si el reloj de un servidor está desfasado, los tokens parecen expirados o "del futuro". Usa NTP en todos.
Conclusión
Keycloak resuelve en una sola pieza lo que cada sistema intentaba resolver por su cuenta: login, contraseñas, 2FA, recuperación y roles. Con OIDC, conectar una aplicación nueva es configurar un cliente y unas cuantas líneas de código, y desactivar a una persona pasa a ser un solo clic. El costo es operar un componente crítico más: trátalo como tal, con alta disponibilidad, respaldos de su base de datos y monitoreo, porque si Keycloak se cae, nadie entra a nada.
compartir:linkedinxblueskywhatsappreddithacker newsfacebookcorreo
Comentarios
Aún no hay comentarios. ¿Quieres dejar el primero?
Deja un comentario
$ ls ~/blog | grep relacionados
Sigue leyendo

Respaldos de PostgreSQL en S3: regla 3-2-1, IAM mínima y restauraciones probadas
Cómo respaldar PostgreSQL en Amazon S3 (o un servicio compatible) de forma segura: pg_dump en streaming, un usuario IAM que solo puede subir, ciclo de vida para la retención, protección contra borrados y un script que prueba la restauración de forma automática.

Cómo autenticarse en el SII con certificado digital desde Node.js: semilla, token y mTLS
Guía práctica para autenticarse en los servicios del SII de Chile con un certificado digital desde Node.js: cómo leer el .pfx, firmar la semilla con XMLDSig, obtener el token, usar TLS mutuo y resolver los errores más comunes.

Tu propio Vercel en un VPS: despliegues con Coolify y Dokploy
Cómo montar una plataforma de despliegue propia en un VPS con Coolify o Dokploy: push a Git y despliegue automático, SSL con Let's Encrypt, bases de datos y previews, más una comparación honesta con Vercel y sus límites reales.