SSO (Single Sign-On) Implementation for Web Application
SSO is not just "login with Google". It's an architectural decision affecting session model, token security, authentication lifecycle, and corporate infrastructure integration. Poorly designed SSO becomes a single point of failure — literally: if Identity Provider is unavailable, users cannot log into any application.
Protocols and Their Applicability
Two current standards: SAML 2.0 and OpenID Connect (OIDC). SAML is used in corporate environments (Azure AD, Okta, ADFS) — XML-based, cumbersome, but universally supported. OIDC is built on OAuth 2.0, JSON-based, native to web and mobile.
For new projects, choice is almost always OIDC. Exception — integration with legacy enterprise IdP supporting only SAML. In this case, install a broker (Keycloak, Dex) that accepts SAML and provides OIDC downstream.
Basic Authorization Code flow with PKCE:
Browser → /authorize?response_type=code&code_challenge=... → IdP
IdP → callback?code=AUTH_CODE → App
App → POST /token (code + code_verifier) → IdP
IdP → { access_token, id_token, refresh_token }
App → validate id_token signature → create session
PKCE is mandatory for public clients (SPA, mobile) — protects against authorization code interception.
Architecture with Multiple Applications
In classic SSO, central session is stored at IdP, applications maintain their own short-lived sessions. Single Logout (SLO) mechanism requires special attention: on logout from one app, IdP must notify others via backchannel (server-to-server) or front-channel (redirects through browser).
Multiple Service Provider scheme:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ App A │ │ App B │ │ App C │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└───────────────┴───────────────┘
│
┌──────▼──────┐
│ IdP │
│ (Keycloak) │
└─────────────┘
Each application is a client in IdP terminology with its own configuration: allowed redirect URIs, scopes, token lifetime.
Token Validation
id_token is a JWT. Validation is mandatory on each request if token is passed as credentials. Minimum checks:
from jwt import PyJWT, algorithms
import requests
def validate_id_token(token: str, client_id: str, issuer: str) -> dict:
# Get IdP public keys
jwks_uri = f"{issuer}/.well-known/openid-configuration"
config = requests.get(jwks_uri).json()
jwks = requests.get(config["jwks_uri"]).json()
header = PyJWT.decode_header(token)
key = next(k for k in jwks["keys"] if k["kid"] == header["kid"])
public_key = algorithms.RSAAlgorithm.from_jwk(key)
claims = PyJWT.decode(
token,
public_key,
algorithms=["RS256"],
audience=client_id,
issuer=issuer,
options={"verify_exp": True}
)
return claims
IdP keys should be cached with TTL, not requested on every call. Simultaneously, need ability to invalidate cache on key rotation (keys_changed event or just short TTL 1–6 hours).
Laravel Application Integration
Laravel has socialite package for simple providers (Google, GitHub) and league/oauth2-client for custom OIDC. For corporate SSO often use aacotroneo/laravel-saml2 or direct integration via firebase/php-jwt.
Example OIDC callback:
// routes/web.php
Route::get('/auth/callback', [SsoController::class, 'callback']);
// SsoController.php
public function callback(Request $request): RedirectResponse
{
$code = $request->input('code');
$state = $request->input('state');
// Verify state against CSRF
if ($state !== session('oauth_state')) {
abort(400, 'Invalid state');
}
$tokens = $this->oidcClient->exchangeCode($code);
$claims = $this->oidcClient->validateIdToken($tokens['id_token']);
$user = User::updateOrCreate(
['sub' => $claims['sub']],
[
'email' => $claims['email'],
'name' => $claims['name'],
'provider' => 'corporate_sso',
'last_login' => now(),
]
);
Auth::login($user, remember: true);
return redirect()->intended('/dashboard');
}
Field sub (subject) — stable user identifier from IdP. Email can change, sub cannot. Link accounts by sub, not email.
Single Logout
SLO is more complex than it seems. Backchannel logout: IdP sends POST to logout endpoint of each app with logout_token (JWT with sid). App finds session by sid and destroys it.
Route::post('/auth/backchannel-logout', function (Request $request) {
$logoutToken = $request->input('logout_token');
$claims = validateLogoutToken($logoutToken); // similar to id_token
$sessionId = $claims['sid'];
// Delete all sessions with given SSO session ID
DB::table('sessions')
->where('sso_session_id', $sessionId)
->delete();
return response()->noContent();
})->middleware('throttle:60,1');
Front-channel logout works via iframe: IdP opens logout URL of each app in hidden frames. Reliability lower — depends on browser policy (ITP in Safari blocks third-party cookies in iframe).
Error Handling and Edge Cases
- IdP unavailable: need fallback — local login form with password or graceful degradation with "SSO temporarily unavailable" message
- IdP session expiration during active work: token refresh via refresh_token should happen transparently, without user interruption
- User email change: if IdP updated email, app should respond correctly — don't create duplicate account
-
Multi-tenancy: different clients may have different IdPs. Determine IdP by email domain or tenant_id in URL (
app.example.com/{tenant}/login)
Implementation Timeline
- Integration with one OIDC provider (Google Workspace, Azure AD) — 3–5 days
- Own IdP on Keycloak with multiple apps — 2–3 weeks
- SAML + OIDC broker with multi-tenancy — from 4 weeks
Most time goes not to code but to IdP configuration, testing authentication edge cases, and SLO setup. Tokens that "seem to work" may contain invalid claims or fail validation in production due to server clock drift (claim iat/exp).







