SSO Authentication: From Controller to Middleware Pattern in Laravel
In this deep-dive technical article, I’ll share how we refactored authentication architecture in a Laravel API — evolving from a controller-based SSO approach to a unified middleware pattern. We’ll explore the security considerations and architectural decisions that drove these changes.
Overview of Authentication Layers
The API implements a multi-layered authentication system to support different client types:
- UserToken Authentication — For App API consumers
- SSO Token Authentication — For embedded iframe/widget functionality
- API-Token Authentication — For third-party integrations
Each layer serves a specific purpose while maintaining security best practices.
The Problem: Scattered Authentication Logic
Initially, SSO token verification lived in a dedicated SSOController. While functional, this created several issues:
- Code duplication — Similar JWT validation logic existed in multiple places
- Inconsistent security checks — Route protection varied across controllers
- Maintenance burden — Changes required updates in multiple files
- Testing complexity — Each controller needed separate auth test suites
The Solution: Unified Middleware Pattern
We refactored authentication into a single Authenticated middleware that handles all token types. Here’s the architecture:
<?php
namespace App\Http\Middleware;
use App\Models\SsoToken;
use App\Models\User;
use App\Models\UserAPI;
use Carbon\Carbon;
use Closure;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\SignatureInvalidException;
use Illuminate\Support\Facades\Auth;
class Authenticated
{
/**
* Routes requiring enterprise plan access.
*/
private $enterpriseRoutes = [
'/monitor/billing/',
'/monitor/settings/',
'/monitor/logout',
'/monitor/session'
];
/**
* Whitelisted routes for SSO token access.
*/
private array $ssoTokenRoutes = [
'monitor/site/view/{site}',
'monitor/dashboard/data/{siteId?}',
'monitor/site/state/{site}',
'monitor/site/logs/{site}/{days}',
// ... more routes
];
public function handle($request, Closure $next, $guard = null)
{
// Layer 1: UserToken (App API)
if ($request->hasHeader('UserToken')) {
return $this->handleUserToken($request, $next);
}
// Layer 2: Site-Token (SSO/iframe)
if ($request->hasHeader('Site-Token')) {
return $this->handleSSOToken($request, $next);
}
// No valid authentication
if (!Auth::check()) {
return response('Authentication required.', 401);
}
return $next($request);
}
}
SSO Token System Deep Dive
Token Generation
SSO tokens are JWT-based with database persistence for additional validation:
public function generateSSOToken(GenerateSSOToken $request, Site $site): JsonResponse
{
// Validate site is properly configured
$counters = $site->counters;
if (is_null($counters) || $counters['components_count'] == 0) {
return response()->json([
'error' => 'Site not properly configured.'
], 409);
}
// Get authenticated user
$userAPI = UserAPI::with('user')
->where('token', $request->header('UserToken'))
->first();
$user = $userAPI->user;
// Generate JWT with 1-hour expiry
$expiresAt = Carbon::now()->addHour();
$payload = [
'user_id' => $user->id,
'site_id' => $site->id,
'exp' => $expiresAt->timestamp,
];
$token = JWT::encode($payload, config('app.key'), 'HS256');
// Persist to database for validation
SsoToken::updateOrCreate(
['user_id' => $user->id],
[
'site_id' => $site->id,
'token' => $token,
'ip_address' => $request->input('ip_address'),
'can_write' => $request->input('can_write', 0),
'can_write_settings' => $request->input('can_write_settings', 0),
'expires_at' => $expiresAt,
]
);
return response()->json([
'access_token' => $token,
'expires_in' => 3600,
]);
}
Token Validation in Middleware
The middleware performs multi-layer validation:
if ($request->hasHeader('Site-Token')) {
$ssoToken = $request->header('Site-Token');
// 1. Check route whitelist
if (!in_array($request->route()->uri(), $this->ssoTokenRoutes)) {
return response()->json(['error' => 'Unauthorized route'], 401);
}
try {
// 2. Decode and verify JWT signature
$decoded = JWT::decode($ssoToken, new Key(config('app.key'), 'HS256'));
$userId = $decoded->user_id;
$siteId = $decoded->site_id;
// 3. Validate site parameter matches token
if (stripos($request->route()->uri(), '{site}') !== false) {
if ($request->route()->originalParameter('site') != $siteId) {
return response()->json(['error' => 'Unauthorized'], 401);
}
}
// 4. Verify token exists in database and not expired
$ssoRecord = SsoToken::where('user_id', $userId)
->where('token', $ssoToken)
->where('expires_at', '>', now())
->first();
if (!$ssoRecord) {
return response()->json(['error' => 'Invalid or expired SSO Token'], 401);
}
// 5. Validate IP address binding
if ($ssoRecord->ip_address && $ssoRecord->ip_address !== $request->ip()) {
return response()->json(['error' => 'Unauthorized IP address'], 401);
}
// 6. Extend token validity (sliding expiration)
$ssoRecord->expires_at = Carbon::now()->addHour();
$ssoRecord->accessed = 1;
$ssoRecord->save();
} catch (SignatureInvalidException $e) {
return response()->json(['error' => 'Invalid token signature'], 401);
}
Auth::setUser(User::find($decoded->user_id));
}
The SsoToken Model
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SsoToken extends Model
{
protected $fillable = [
'user_id',
'site_id',
'token',
'ip_address',
'accessed',
'can_write',
'can_write_settings',
'expires_at'
];
protected $casts = [
'expires_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function site(): BelongsTo
{
return $this->belongsTo(Site::class);
}
}
Authorization with FormRequests
We use Laravel FormRequests for granular authorization:
<?php
namespace App\Http\Requests\Site;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
class GenerateSSOToken extends FormRequest
{
public function authorize(): bool
{
$user = auth()->user();
// Must be authenticated
if (!$user) {
return false;
}
// Must be enterprise user or admin
if ($user->class != 7 && !$user->is_admin) {
return false;
}
// Must own the resource
if (!$user->canAlter($this->route('site')->id)) {
return false;
}
return true;
}
protected function failedAuthorization()
{
throw new HttpResponseException(
response()->json([
'success' => false,
'message' => 'You are not authorized to access this resource.',
'code' => 403,
], 403)
);
}
}
Token Cleanup with Artisan Commands
Expired tokens are cleaned up via scheduled command:
<?php
namespace App\Console\Commands;
use App\Models\SsoToken;
use Carbon\Carbon;
use Illuminate\Console\Command;
class DeleteExpiredSSOTokens extends Command
{
protected $signature = 'sso:cleanup';
protected $description = 'Delete expired SSO tokens older than 24 hours.';
public function handle()
{
$deleted = SsoToken::where('expires_at', '<', Carbon::now()->subDay())->delete();
$this->info("Deleted {$deleted} expired tokens.");
return Command::SUCCESS;
}
}
Schedule in Kernel.php:
$schedule->command('sso:cleanup')->hourly();
API Token Authentication for Third-Party Integrations
For external integrations, we use a separate middleware:
<?php
namespace App\Http\Middleware;
use App\Models\UserAPI;
use Closure;
class VerifyUserAPIToken
{
public function handle(Request $request, Closure $next)
{
if (!$request->hasHeader('API-Token')) {
return response()->json([
'error' => 'The API-Token header is missing.'
], 401);
}
$token = UserAPI::with('user')
->where('token', $request->header('API-Token'))
->first();
if (is_null($token) || !$token->user || $token->user->class == 0) {
return response()->json([
'error' => 'Unauthorized API-Token.'
], 401);
}
// Merge token into request for downstream use
$request->offsetSet('token', $token);
return $next($request);
}
}
Security Best Practices Implemented
1. Route Whitelisting
SSO tokens can only access explicitly whitelisted routes:
private array $ssoTokenRoutes = [
'monitor/site/view/{site}',
'monitor/dashboard/data/{siteId?}',
// Strictly defined - no wildcards
];
2. Resource Parameter Validation
Tokens are bound to specific resources:
if ($request->route()->originalParameter('site') != $siteId) {
return response()->json(['error' => 'Unauthorized'], 401);
}
3. IP Address Binding
Optional IP restriction prevents token theft:
if ($ssoRecord->ip_address && $ssoRecord->ip_address !== $request->ip()) {
return response()->json(['error' => 'Unauthorized IP address'], 401);
}
4. Sliding Expiration
Active sessions are automatically extended:
$ssoRecord->expires_at = Carbon::now()->addHour();
$ssoRecord->save();
5. Dual Validation (JWT + Database)
Tokens must be valid JWTs AND exist in database:
// JWT validation
$decoded = JWT::decode($ssoToken, new Key(config('app.key'), 'HS256'));
// Database validation
$ssoRecord = SsoToken::where('token', $ssoToken)
->where('expires_at', '>', now())
->first();
Benefits of the Middleware Pattern
| Before (Controller) | After (Middleware) |
|---|---|
| Auth logic in multiple controllers | Single source of truth |
| Easy to bypass checks | Enforced on all routes |
| Harder to test | Isolated, testable unit |
| Inconsistent error responses | Standardized responses |
| Route-specific implementations | Declarative route protection |
Key Takeaways
Moving SSO token verification from controllers to middleware provided:
- Centralized security — All auth logic in one place
- Consistent enforcement — No route can bypass checks
- Cleaner controllers — Focus on business logic only
- Easier auditing — Single file to review for auth issues
- Scalable pattern — Easy to add new token types
This pattern has proven robust in production, handling thousands of SSO-authenticated requests daily while maintaining strict security boundaries.
The key insight: authentication belongs in middleware, not controllers. Controllers should trust that the request is already authenticated and focus solely on their domain logic.