plugins/full-stack-auth/skills/implementing-scalekit-laravel-auth/SKILL.md
Implements Scalekit authentication in a Laravel app using the patterns from scalekit-inc/scalekit-laravel-auth-example. Handles login, OAuth callback, Laravel session storage, automatic token refresh via middleware, logout, and permission-based route protection. Uniquely uses Laravel's Http facade with raw HTTP calls instead of a PHP SDK — no official Scalekit PHP SDK exists. Use when adding auth controllers, protecting routes with middleware, managing sessions, or checking permissions in a Laravel + Scalekit codebase.
npx skillsauth add scalekit-inc/claude-code-authstack implementing-scalekit-laravel-authInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Reference repo: scalekit-inc/scalekit-laravel-auth-example
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
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()`
| 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.
$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);
$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'] ?? '');
}
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').
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
GET /auth/callback → AuthController::callback)$request->query('state') vs session('oauth_state') → response()->view('auth.error', [...], 400) on mismatchsession()->forget('oauth_state')$tokenResponse = $this->scalekitClient->exchangeCodeForTokens($code)$idTokenClaims$userInfo = $this->scalekitClient->getUserInfo($accessToken) → access token claims$mergedClaims = array_merge($userInfo, $idTokenClaims) — ID token wins (overlaid last)$expiresAt = now()->addSeconds($expiresIn)return redirect()->route('auth.dashboard')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
POST /sessions/refresh-token)On invalid_grant error: session()->flush() + return 401 with 'requiresReauth' => true.
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 gateRedirects to auth.login with ->with('next', $request->path()) if scalekit_user session key is missing.
ScalekitPermission — parameterised permission checkValidates 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 requestSkipped 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/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:
GET and POST (Route::match)auth. prefix throughout — use route('auth.dashboard') in Blade| 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 |
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);
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.
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.
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
]);
})
// 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').
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.
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);
}
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.
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.
development
Walks through a structured production readiness checklist for Scalekit SSO implementations. Use when the user says they are going live, launching to production, doing a pre-launch review, hardening their SSO setup, or wants to verify their Scalekit implementation is production-ready.
data-ai
Implements complete SSO and authentication flows using Scalekit. Handles modular SSO, IdP-initiated login, user session management, and enterprise customer onboarding. Use when adding authentication, SSO, SAML, OIDC, or user login to applications.
testing
Implements Scalekit's admin portal for customer self-serve SSO and SCIM configuration. Generates portal links server-side and embeds the portal as an iframe in the app's settings UI. Use when the user asks to add an admin portal, customer self-serve SSO setup, iframe embed for SSO config, shareable setup link, or let customers configure their own SSO or SCIM connection.
development
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.