Skip to content
All posts
LaravelSecuritySSO

How I Integrated MyDigitalID SSO into Laravel Using Keycloak (Session-Based Flow)

March 25, 2026·Read on Medium·

A practical guide to wiring Malaysia’s national identity platform into a Laravel app. Custom provider, NRIC lookup and stateful session auth included.

Most Laravel SSO tutorials stop at Google or GitHub. Fair enough. But if you are building government-facing apps in Malaysia, there is a different provider you need to know: MyDigitalID.

MyDigitalID is Malaysia’s national digital identity platform. It lets citizens authenticate using their MyKad credentials through an OIDC-compatible SSO service. Under the hood, it runs on Keycloak, the same open-source identity platform used across enterprise environments worldwide.

This article covers the stateful browser session flow, the standard approach for server-rendered Laravel apps where users log in through a browser, not a mobile app or SPA consuming an API.

Stateful vs Stateless: Why It Matters Here

Before the code, a quick distinction.

The stateless flow (common in API-first apps) skips session storage entirely. You call Socialite::driver('keycloak')->stateless()->redirect() on the way out and ->stateless()->user() on the way back. Authentication ends with issuing a Sanctum token to the client.

The stateful flow is the opposite. Socialite stores a random state value in the session before the redirect. When Keycloak sends the user back, Socialite reads that value from the session and compares it against the state query parameter. If they do not match, the request is rejected. This is Socialite's built-in CSRF protection for browser flows.

Authentication ends with Auth::login($user), not a token. The session holds the authenticated user from that point forward.

No cache keys. No encrypted tokens. No 2-minute TTL dance. The session handles all of it.

The Configuration

Add your Keycloak credentials to 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'),
'realms' => env('KEYCLOAK_REALM'),
],

base_url is your Keycloak server URL. realms is your realm name. MyDigitalID runs staging and production on separate realms. These values come from the MyIDSSO onboarding process after you register your application.

Make sure your KEYCLOAK_REDIRECT_URI points to a web route, not an API route. Sessions do not exist on api middleware. They live on web.

Registering a Custom Provider

The socialiteproviders/keycloak package handles standard OIDC out of the box. But MyDigitalID adds a non-standard JWT claim, nric, which the base package does not map.

Override mapUserToObject() with a custom provider class and register it via the SocialiteWasCalled event in your AppServiceProvider:

$event->extendSocialite('keycloak', \App\Resolvers\SSO\Keycloak\Provider::class);

The custom provider:

<?php

namespace App\Resolvers\SSO\Keycloak;
use Illuminate\Support\Arr;
use SocialiteProviders\Keycloak\Provider as KeycloakProvider;
use SocialiteProviders\Manager\OAuth2\User;
class Provider extends KeycloakProvider
{
protected function mapUserToObject(array $user)
{
return (new User())->setRaw($user)->map([
'id' => Arr::get($user, 'sub'),
'nickname' => Arr::get($user, 'preferred_username'),
'name' => Arr::get($user, 'name') ?? Arr::get($user, 'nama'),
'email' => Arr::get($user, 'email'),
'nric' => Arr::get($user, 'nric'),
]);
}
}

Two things to note.

name ?? nama: the token can return the user's full name under either field depending on configuration. The null coalescing fallback covers both.

nric: this is the IC number from the JWT payload. It is not a standard OIDC claim. Without this override, it never surfaces on the Socialite user object.

The Two-Step Flow

Step 1: Redirect to Keycloak

// routes/web.php
Route::get('/auth/login/keycloak', [SocialAuthController::class, 'redirect'])
->name('auth.keycloak.redirect'); public function redirect(): RedirectResponse
{
return Socialite::driver('keycloak')->redirect();
}

No .stateless(). Socialite generates a random state value, stores it in the session and appends it to the Keycloak redirect URL. The user is sent to the Keycloak login page where they authenticate with their MyKad credentials.

Step 2: Keycloak Callback

// routes/web.php
Route::get('/auth/keycloak/callback', [SocialAuthController::class, 'callback'])
->name('auth.keycloak.callback'); public function callback(Request $request): RedirectResponse
{
try {
$providerUser = Socialite::driver('keycloak')->user();
} catch (Throwable $e) {
report($e);
return redirect()->route('login')->withErrors(['sso' => 'Authentication failed.']);
}

$user = User::firstOrCreate(
['identity_no' => $providerUser->nric],
['name' => $providerUser->name]
);
if (! $user->active) {
return redirect()->route('login')->withErrors(['sso' => 'This account is not active.']);
}
Auth::login($user, remember: true);
$request->session()->regenerate();
return redirect()->intended('/dashboard');
}

Socialite::driver('keycloak')->user() with no .stateless(). Socialite reads the state from the session, validates it against the query parameter and then exchanges the authorization code for an access token. If the state check fails, it throws. If Keycloak returns an error, it throws.

The user lookup is a single firstOrCreate on identity_no. If the NRIC exists in the database, you get that user back. If not, a new record is created with the NRIC and name from the token. No email. No password.

The active check runs before Auth::login(). A deactivated account gets rejected here regardless of valid Keycloak credentials.

$request->session()->regenerate() issues a new session ID after login. This prevents session fixation attacks, the same step Laravel's own LoginController takes and one you should replicate here.

Why firstOrCreate and Not a Manual Query

firstOrCreate is the right tool here for one reason: the first time a user logs in via MyDigitalID, they have no record in your database. You need to create one on the spot without making them fill in a registration form.

The call looks like this:

$user = User::firstOrCreate(
['identity_no' => $providerUser->nric],
['name' => $providerUser->name]
);

The first argument is the lookup condition. The second is what gets written if no record is found. On subsequent logins, firstOrCreate just returns the existing user without touching the database a second time.

One thing to be careful about: make sure identity_no has a unique index on the table. Without it, a race condition under concurrent requests could create duplicate records for the same NRIC.

NRIC as the Identifier

This is the part that trips people up when coming from Google or GitHub integrations.

Most providers use email as the unique identifier. MyDigitalID uses IC number. The same person can have multiple email addresses. Their IC number is singular and government-issued, which makes it the right key for a national identity platform.

The practical consequence: your users table needs an identity_no column. If you are adding this to an existing app built on email uniqueness, run the migration and check your uniqueness constraints before going anywhere near production.

Routes Must Use Web Middleware

This is easy to get wrong if you are used to building API-first apps.

Sessions only exist under the web middleware group. If your callback route is defined in routes/api.php or uses the api middleware, Socialite cannot read the session state it stored during the redirect. The state check fails and every login attempt throws.

Define both the redirect and callback routes in routes/web.php:

Route::middleware(['web'])->group(function () {
Route::get('/auth/login/keycloak', [SocialAuthController::class, 'redirect'])
->name('auth.keycloak.redirect');

Route::get('/auth/keycloak/callback', [SocialAuthController::class, 'callback'])
->name('auth.keycloak.callback');
});

Your KEYCLOAK_REDIRECT_URI in .env must match this callback URL exactly, including scheme, domain and path. Keycloak validates the redirect URI against the list registered for your client. A mismatch returns an error before the user even sees a login form.

Official Resources

MyIDSSO maintains an official integration guideline repository on GitHub at github.com/MyIDSSO/SSO-Integration-Guideline. The latest release at the time of writing is v5.0. They also publish sample code repositories for Laravel 10, CodeIgniter 4, Next.js, Flutter and others.

The guideline document covers realm configuration, client registration and the difference between staging and production environments. Read it before you start. The endpoint values are not something you want to guess at.

Final Thoughts

The stateful browser flow is simpler than the stateless API equivalent. No cache keys, no encrypted sso_tokens and no signed route handoff. Socialite’s session-backed state mechanism handles CSRF protection for you. You redirect, Keycloak authenticates and you call Auth::login().

The MyDigitalID-specific parts are contained: a 15-line custom provider class to surface the nric claim and a branch in your user resolution logic to look up by identity_no instead of email.

Get those two pieces right and the rest is standard Laravel.

Found this helpful?

If this article saved you time or solved a problem, consider supporting — it helps keep the writing going.

Originally published on Medium.

View on Medium
How I Integrated MyDigitalID SSO into Laravel Using Keycloak (Session-Based Flow) — Hafiq Iqmal — Hafiq Iqmal