implementing-scalekit-laravel-auth
Scalekit Auth — Laravel
Reference repo: scalekit-inc/scalekit-laravel-auth-example
Project structure
app/
├── Services/
│ └── ScalekitClient.php # Raw HTTP OAuth client (no PHP SDK)
├── Http/
│ ├── Controllers/
│ │ └── AuthController.php
│ └── Middleware/
│ ├── ScalekitAuth.php # Session auth gate
│ ├── ScalekitPermission.php # Per-route permission check
│ └── ScalekitTokenRefresh.php # Auto token refresh on every request
config/
└── scalekit.php # Reads from env via config('scalekit.*')
routes/
└── web.php # Named routes + middleware groups
Environment variables
SCALEKIT_ENV_URL=https://your-env.scalekit.io
SCALEKIT_CLIENT_ID=your-client-id
SCALEKIT_CLIENT_SECRET=your-client-secret
SCALEKIT_REDIRECT_URI=http://localhost:8000/auth/callback
Scopes are hardcoded in config/scalekit.php, not from env:
'scopes' => 'openid profile email offline_access',
// offline_access is required to receive a refresh token
ScalekitClient service (app/Services/ScalekitClient.php)
⚠️ No official Scalekit PHP SDK exists. This app uses Laravel's
Httpfacade with raw HTTP calls. Always useconfig('scalekit.*')— do not readenv()directly:
use App\Services\ScalekitClient;
// Injected via Laravel's service container — never `new ScalekitClient()`
Key methods and their HTTP calls
| Method | HTTP call | Auth |
|---|---|---|
getAuthorizationUrl($state) |
Builds {env_url}/oauth/authorize?response_type=code&... |
None |
exchangeCodeForTokens($code) |
POST {env_url}/oauth/token with grant_type=authorization_code |
Basic Auth |
refreshAccessToken($refreshToken) |
POST {env_url}/oauth/token with grant_type=refresh_token |
Basic Auth |
getUserInfo($accessToken) |
Delegates to validateTokenAndGetClaims() |
— |
validateTokenAndGetClaims($token) |
Manual base64 JWT decode — no signature verification | — |
hasPermission($token, $permission) |
Decodes JWT, checks permission claim chain | — |
logout($accessToken) |
Builds {env_url}/oidc/logout?post_logout_redirect_uri=... |
None |
isTokenExpired($expiresAt) |
now()->addMinutes(5)->gt(Carbon::parse($expiresAt)) |
— |
Token exchange and refresh use Http::asForm()->withBasicAuth(clientId, clientSecret). Both fall back to expires_in = 3600 if the field is missing.
JWT decode pattern (used in both token validation and ID token decode)
$parts = explode('.', $token);
$payload = $parts[1];
$payload .= str_repeat('=', (4 - strlen($payload) % 4) % 4); // padding
$decoded = base64_decode(strtr($payload, '-_', '+/')); // URL-safe base64
$claims = json_decode($decoded, true);
Permission claim fallback chain
$permissions = $claims['permissions']
?? $claims['https://scalekit.com/permissions']
?? $claims['scalekit:permissions']
?? [];
// Also falls back to scope string if all are empty
if (empty($permissions)) {
$permissions = explode(' ', $claims['scope'] ?? '');
}
Session storage schema
All auth state lives in Laravel's session — no extra DB tables (uses default database or file driver):
session([
'scalekit_user' => [
'sub', 'email', 'name', 'given_name', 'family_name',
'preferred_username',
'claims' // merged array of ALL claims (ID token overlaid on access token)
],
'scalekit_tokens' => [
'access_token', 'refresh_token', 'id_token',
'expires_at', // Carbon ISO 8601 string via ->toIso8601String()
'expires_in', // int seconds
],
'scalekit_roles' => [], // from access token claims
'scalekit_permissions' => [], // from access token claims
]);
Check auth status anywhere: session()->has('scalekit_user').
Auth flow
Login (GET /login → AuthController::login)
$state = Str::random(32); // Illuminate\Support\Str
session(['oauth_state' => $state]);
$authUrl = $this->scalekitClient->getAuthorizationUrl($state);
return view('auth.login', ['auth_url' => $authUrl]);
// Template renders a link/button to $auth_url
Callback (GET /auth/callback → AuthController::callback)
- Validate
$request->query('state')vssession('oauth_state')→response()->view('auth.error', [...], 400)on mismatch session()->forget('oauth_state')$tokenResponse = $this->scalekitClient->exchangeCodeForTokens($code)- Manually decode ID token →
$idTokenClaims $userInfo = $this->scalekitClient->getUserInfo($accessToken)→ access token claims- Merge:
$mergedClaims = array_merge($userInfo, $idTokenClaims)— ID token wins (overlaid last) $expiresAt = now()->addSeconds($expiresIn)- Write all four session keys
return redirect()->route('auth.dashboard')
Logout (GET|POST /logout → AuthController::logout)
$logoutUrl = $this->scalekitClient->logout($accessToken);
// → {env_url}/oidc/logout?post_logout_redirect_uri={base_url}
// post_logout_redirect_uri is derived from SCALEKIT_REDIRECT_URI, stripping /auth/callback
session()->flush(); // Full session wipe
return redirect($logoutUrl); // Server-side redirect to Scalekit
Token refresh — controller (POST /sessions/refresh-token)
On invalid_grant error: session()->flush() + return 401 with 'requiresReauth' => true.
Middleware
Registration in bootstrap/app.php (Laravel 11) or Kernel.php (Laravel ≤10)
// Laravel 11 — bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'scalekit.auth' => \App\Http\Middleware\ScalekitAuth::class,
'scalekit.permission' => \App\Http\Middleware\ScalekitPermission::class,
]);
$middleware->append(\App\Http\Middleware\ScalekitTokenRefresh::class);
})
ScalekitAuth — session gate
Redirects to auth.login with ->with('next', $request->path()) if scalekit_user session key is missing.
ScalekitPermission — parameterised permission check
Validates access token claims via ScalekitClient::hasPermission(). On failure: response()->view('auth.permission_denied', [...], 403). Never returns a JSON 403 — always renders a view.
ScalekitTokenRefresh — auto refresh on every request
Skipped paths: login, auth/callback, logout, sessions/refresh-token.
Buffer: 5 minutes (via isTokenExpired()). On invalid_grant during auto-refresh: session()->flush() (user gets redirected on next request).
Routes (routes/web.php)
// Public
Route::get('/', [AuthController::class, 'home'])->name('auth.home');
Route::get('/login', [AuthController::class, 'login'])->name('auth.login');
Route::get('/auth/callback', [AuthController::class, 'callback'])->name('auth.callback');
// Protected group
Route::middleware(['scalekit.auth'])->group(function () {
Route::get('/dashboard', [AuthController::class, 'dashboard'])->name('auth.dashboard');
Route::match(['get', 'post'], '/logout', [AuthController::class, 'logout'])->name('auth.logout');
Route::get('/sessions', [AuthController::class, 'sessions'])->name('auth.sessions');
Route::post('/sessions/validate-token', [AuthController::class, 'validateToken'])->name('auth.validate_token');
Route::post('/sessions/refresh-token', [AuthController::class, 'refreshToken'])->name('auth.refresh_token');
// Permission-gated — note colon syntax for middleware parameter
Route::get('/organization/settings', [AuthController::class, 'organizationSettings'])
->middleware('scalekit.permission:organization:settings')
->name('auth.organization_settings');
});
Key notes:
- Logout accepts both
GETandPOST(Route::match) - Permission middleware receives the permission name as a colon-separated parameter
- Named routes use
auth.prefix throughout — useroute('auth.dashboard')in Blade
Route map
| URL | Middleware | Auth |
|---|---|---|
GET / |
— | No |
GET /login |
— | No |
GET /auth/callback |
— | No |
GET /dashboard |
scalekit.auth |
Yes |
GET|POST /logout |
scalekit.auth |
Yes |
GET /sessions |
scalekit.auth |
Yes |
POST /sessions/validate-token |
scalekit.auth |
Yes |
POST /sessions/refresh-token |
scalekit.auth |
Yes |
GET /organization/settings |
scalekit.auth + scalekit.permission:organization:settings |
Yes + permission |
Dependency injection
ScalekitClient is resolved from Laravel's service container in every controller and middleware constructor. No singleton binding needed — Laravel resolves it fresh per request by default. Register it in AppServiceProvider only if you need to scope it as a singleton:
// Optional — only if you want to share a single instance
$this->app->singleton(ScalekitClient::class);
Install
composer require firebase/php-jwt # Only if using JWT signature verification
php artisan key:generate
php artisan migrate # Creates sessions table if using database driver
php artisan serve
Copy .env.example to .env and fill in the four SCALEKIT_* values.
Tactics
SameSite=Lax — required for OAuth callbacks
Verify your session cookie config in config/session.php:
'same_site' => 'lax', // Required — 'strict' breaks OAuth callbacks
'secure' => env('SESSION_SECURE_COOKIE', false), // true in production
'http_only' => true,
SameSite: strict drops the session cookie on the cross-origin redirect from Scalekit back to /auth/callback, making oauth_state unavailable and causing the state mismatch check to fail every time.
CSRF exclusion for the OAuth callback
The OAuth callback is a GET request and is not subject to Laravel's CSRF middleware. However, if you add any Scalekit webhook endpoints (POST), exclude them explicitly. In Laravel 11 (bootstrap/app.php):
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'webhooks/scalekit', // example — callback is GET, not needed here
]);
})
Deep link preservation
// In AuthController::login
$next = $request->query('next', route('auth.dashboard'));
// Validate: only relative paths
if (!str_starts_with($next, '/')) {
$next = route('auth.dashboard');
}
session(['oauth_state' => $state, 'next' => $next]);
// In AuthController::callback — after writing session data
$next = session()->pull('next', route('auth.dashboard'));
if (!str_starts_with($next, '/')) {
$next = route('auth.dashboard');
}
return redirect($next);
ScalekitAuth middleware passes ->with('next', $request->path()) when redirecting to login — read it back in login() with session('next') or $request->query('next').
Cache-Control: no-store on protected responses
return response()
->view('auth.dashboard', ['user' => session('scalekit_user', [])])
->header('Cache-Control', 'no-store');
Prevents the browser back button from serving a cached authenticated page after logout.
AJAX: 401 instead of redirect
Update ScalekitAuth middleware to return 401 for JSON requests:
public function handle(Request $request, Closure $next): Response
{
if (!session()->has('scalekit_user')) {
if ($request->expectsJson()) {
return response()->json(['error' => 'Unauthenticated'], 401);
}
return redirect()->route('auth.login')->with('next', $request->path());
}
return $next($request);
}
CORS for JavaScript clients
Laravel ships with CORS support. In config/cors.php:
'paths' => ['api/*', 'auth/*', 'sessions/*'],
'allowed_origins' => ['http://localhost:3000'], // explicit origin required
'supports_credentials' => true, // required for session cookies
⚠️
'allowed_origins' => ['*']does not work withsupports_credentials => true.
Session fixation after login
After writing all session data in callback(), regenerate the session ID to prevent session fixation:
// At the end of AuthController::callback, after writing session data:
session()->regenerate();
return redirect($next);
session()->regenerate() issues a new session ID while preserving the session data — an attacker who set a known session ID before login cannot use it after authentication.
More from scalekit-inc/skills
setup-scalekit
Use when a developer is new to Scalekit and needs guidance on where to start, doesn't know which auth plugin or skill to choose, wants to connect an AI agent or agentic workflow to third-party services (Gmail, Slack, Notion, Google Calendar), needs OAuth or tool-calling auth for agents, wants to add authentication to a project but hasn't chosen an approach yet, or needs to install the Scalekit plugin for their AI coding tool (Claude Code, Codex, Copilot CLI, Cursor, or other agents).
11implementing-scalekit-fsa
Implements Scalekit full-stack authentication (FSA) including sign-up, login, logout, and secure session management using JWT tokens. Use when building or integrating user authentication with the Scalekit SDK across Node.js, Python, Go, or Java — or when the user asks about auth flows, OAuth callbacks, token refresh, or session handling with Scalekit.
4implementing-fsa-logout
Implements a complete logout flow for Scalekit FSA integrations by clearing application session cookies and redirecting the browser to Scalekit’s /oidc/logout endpoint to invalidate the Scalekit session. Use when adding or fixing logout in Node.js, Python, Go, or Java web apps that use Scalekit OIDC.
2implementing-scim-provisioning
Implements SCIM user provisioning using Scalekit's Directory API and webhooks. Use when the user asks to add SCIM, directory sync, user provisioning, deprovisioning, or lifecycle management to their existing application.
2production-readiness-scim
Walks through a structured production readiness checklist for Scalekit SCIM provisioning implementations. Use when the user says they are going live, launching to production, doing a pre-launch review, or wants to verify their SCIM directory sync implementation is production-ready.
2adding-oauth2-to-apis
>
2