all systems operationalall operational

apenaloza.cl · bitácora

SCL (UTC−3) · v2026.11

~/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
Cover: SSO with Keycloak, one login for Laravel and Next.js with OpenID Connect, with one key connected to three applications
In this article
  1. The bare minimum concepts
  2. Step 1: run Keycloak with Docker
  3. Step 2: configure the realm
  4. Step 3: register the applications as clients
  5. Step 4: Laravel with Socialite
  6. Step 5: Next.js with Auth.js
  7. Global logout
  8. Migrating existing users
  9. Common mistakes
  10. Conclusion

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_HOSTNAME must 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: xforwarded makes 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-dev is enough, with no external database.

Step 2: configure the realm

In the admin console:

  1. Create a realm (for example, company). Don't use the master realm for your applications: it's only for administering Keycloak.
  2. In Realm settings → Login, decide whether users can self-register, reset their password, and log in with their email.
  3. In Authentication → Policies, define the password policy and enable OTP (or require it for administrative roles).
  4. Configure SMTP so email verification and password recovery work.
  5. 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/keycloak

config/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 users table. 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/company

auth.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 sub arrives with an email that exists in the local table and is verified in Keycloak, link them and use the sub from 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_HOSTNAME doesn't match the public URL, or the proxy isn't sending X-Forwarded-Proto. Check /.well-known/openid-configuration and compare the issuer field.
  • 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

Comments are reviewed before they go live. I don’t ask for your email or store your IP.

$ ls ~/blog | grep related

Keep reading