~/blog/sso-keycloak-laravel-nextjs-openid-connect.md
SSO with Keycloak: one login for Laravel and Next.js with OpenID Connect
How to implement single sign-on (SSO) across multiple systems with Keycloak and OpenID Connect: Docker deployment, realm and client setup, Laravel and Next.js integration, global logout, roles, and the mistakes that keep coming up.
- author
- Alejandro Peñaloza
- Published
- Reading
- 5 min
- Español
- Leer en español →

In this article
As a company grows one system at a time, users end up with a different password for each one: the ERP, the customer portal, the reporting app, the admin panel. Every system has its own users table, its own password recovery and its own permissions. And every time someone leaves the company, you have to remember to deactivate them everywhere.
The solution is single sign-on (SSO): one place where people authenticate, and every system trusts that identity. In this post I implement it with Keycloak, an open source identity server, connecting a Laravel application and a Next.js one.
The bare minimum concepts
- OpenID Connect (OIDC): the standard authentication protocol, built on top of OAuth 2.0. Your applications never see the password; they receive a signed token that says who the user is.
- Identity provider (IdP): the server that authenticates users, in this case Keycloak.
- Realm: an isolated space in Keycloak with its own users, roles and clients. Usually one per organization.
- Client: each application that delegates login to Keycloak.
- Authorization code flow: the recommended flow. The application redirects the user to Keycloak, Keycloak comes back with a code, and the application exchanges it for tokens from the server side.
sequenceDiagram
participant U as User
participant A as Laravel App
participant K as Keycloak
participant B as Next.js App
U->>A: Opens app A
A->>K: Redirects to /auth
U->>K: Username and password (+2FA)
K-->>A: Authorization code
A->>K: Exchanges code for tokens
A-->>U: Logged in to A
U->>B: Opens app B
B->>K: Redirects to /auth
K-->>B: Session exists: code without asking for password
B-->>U: Logged in to B
The magic of SSO happens on the second login: Keycloak already has a session with the user, so it hands the code to application B without asking for credentials again.
Step 1: run Keycloak with Docker
In production, Keycloak needs its own database and must run behind an HTTPS proxy.
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.mydomain.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:Put it behind Nginx or Traefik with a certificate for auth.mydomain.cl. Key points:
KC_HOSTNAMEmust be the exact public URL. If it doesn't match, tokens are issued with a different issuer and the applications reject them.KC_PROXY_HEADERS: xforwardedmakes Keycloak trust the proxy headers to know the original connection was HTTPS. Make sure only the proxy can reach port 8080.- The bootstrap admin account is temporary: log in, create a permanent administrator with 2FA, and delete the bootstrap one.
- For local development,
command: start-devis enough, with no external database.
Step 2: configure the realm
In the admin console:
- Create a realm (for example,
company). Don't use themasterrealm for your applications: it's only for administering Keycloak. - In Realm settings → Login, decide whether users can self-register, reset their password, and log in with their email.
- In Authentication → Policies, define the password policy and enable OTP (or require it for administrative roles).
- Configure SMTP so email verification and password recovery work.
- Create the realm roles shared by all systems (for example,
admin,finance,teacher).
Step 3: register the applications as clients
For each application, go to Clients → Create client:
| Field | Laravel | Next.js |
|---|---|---|
| Client ID | app-erp |
portal |
| Client authentication | On (confidential client) | On (login runs on the server) |
| Flow | Standard flow | Standard flow |
| Valid redirect URIs | https://erp.mydomain.cl/auth/callback |
https://portal.mydomain.cl/api/auth/callback/keycloak |
| Valid post logout redirect URIs | https://erp.mydomain.cl/ |
https://portal.mydomain.cl/ |
| Web origins | https://erp.mydomain.cl |
https://portal.mydomain.cl |
Disable Direct access grants and Implicit flow if you don't need them. Copy the client secret from the Credentials tab.
To get roles into the token, check under Client scopes that the roles scope is assigned; Keycloak includes realm roles in the realm_access.roles claim of the access token.
Step 4: Laravel with 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.mydomain.cl
'realms' => env('KEYCLOAK_REALM'), // company
],Register the provider (in Laravel 11 or later, in the boot method of AppServiceProvider):
use Illuminate\Support\Facades\Event;
use SocialiteProviders\Manager\SocialiteWasCalled;
Event::listen(function (SocialiteWasCalled $event) {
$event->extendSocialite('keycloak', \SocialiteProviders\Keycloak\Provider::class);
});Routes and controller:
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()], // stable identifier
['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);
}
}Two important decisions:
- Link users by
sub, Keycloak's immutable identifier, not by email (which can change). - Keep your local
userstable. Keycloak answers who the user is; your application still stores its own relationships and business data.
Step 5: Next.js with Auth.js
npm install next-auth@beta.env.local:
AUTH_SECRET=generate-one-with-npx-auth-secret
AUTH_KEYCLOAK_ID=portal
AUTH_KEYCLOAK_SECRET=the-client-secret
AUTH_KEYCLOAK_ISSUER=https://auth.mydomain.cl/realms/companyauth.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;Protecting a page by role:
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function Finance() {
const session = await auth();
if (!session) redirect("/api/auth/signin");
if (!session.user.roles?.includes("finance")) return <p>No access</p>;
return <h1>Finance dashboard</h1>;
}Here the token has already been validated by Auth.js during login, which is why reading the payload is safe at that point. If an API receives tokens directly from the browser, it must verify the signature against Keycloak's JWKS (/realms/company/protocol/openid-connect/certs), for example with the jose library.
Global logout
SSO has a counterpart people tend to forget: if the user logs out of one application, what happens to the others?
- RP-initiated logout: redirect to Keycloak's logout endpoint with
id_token_hint, as in the Laravel controller. That ends the session in Keycloak. - Back-channel logout: on each client you configure a Backchannel logout URL. When the Keycloak session ends, Keycloak calls that URL on every application with a signed logout token, and the application invalidates that user's local session.
Without back-channel logout, the other applications keep their local session until it expires. For internal systems handling sensitive information, implement it.
Migrating existing users
You rarely start from scratch. Options:
- Bulk import through the Keycloak admin API, sending a "set your password" email on first login.
- User federation (LDAP or Active Directory) if a directory already exists.
- Email-based linking on first login: if a new
subarrives with an email that exists in the local table and is verified in Keycloak, link them and use thesubfrom then on.
Common mistakes
- "Invalid redirect_uri": the URL must match the registered one exactly, including scheme, port and trailing slash.
- "Invalid issuer" or rejected tokens:
KC_HOSTNAMEdoesn't match the public URL, or the proxy isn't sendingX-Forwarded-Proto. Check/.well-known/openid-configurationand compare theissuerfield. - Redirect loops: cookies blocked by
SameSite, or the application sits behind a proxy without trusting its headers (in Laravel, configure trusted proxies). - Huge tokens: with many roles and groups, the token exceeds cookie or header size limits. Include only the claims you need using dedicated client scopes.
- Clock skew: if a server's clock drifts, tokens look expired or "from the future". Use NTP everywhere.
Conclusion
Keycloak solves in a single piece what each system was trying to solve on its own: login, passwords, 2FA, recovery and roles. With OIDC, connecting a new application means configuring a client and writing a few lines of code, and deactivating a person becomes a single click. The cost is running one more critical component: treat it as such, with high availability, database backups and monitoring, because if Keycloak goes down, nobody gets into anything.
share:linkedinxblueskywhatsappreddithacker newsfacebookemail
Comments
No comments yet. Want to leave the first one?
Leave a comment
$ ls ~/blog | grep related
Keep reading

PostgreSQL Backups to S3: The 3-2-1 Rule, Least-Privilege IAM and Tested Restores
How to back up PostgreSQL to Amazon S3 (or any S3-compatible service) safely: streaming pg_dump, an IAM user that can only upload, lifecycle rules for retention, protection against deletion and a script that tests restores automatically.

How to Authenticate with the SII Using a Digital Certificate in Node.js: Seed, Token, and mTLS
A practical guide to authenticating against the web services of Chile's tax authority (SII) with a digital certificate from Node.js: reading the .pfx, signing the seed with XMLDSig, getting the token, using mutual TLS, and fixing the most common errors.

Your own Vercel on a VPS: deployments with Coolify and Dokploy
How to run your own deployment platform on a VPS with Coolify or Dokploy: git push to auto-deploy, Let's Encrypt SSL, databases and previews, plus an honest comparison with Vercel and its real limits.