Reference repo: scalekit-inc/scalekit-nextjs-auth-example
Guardrails
- MUST validate auth in
middleware.tsfor protected paths AND re-check withisAuthenticated()in Server Components; MUST NOT rely onmiddleware.tsas the only auth check. - MUST store session tokens in the
scalekit_sessionHttpOnly cookie; MUST NOT exposeaccess_token/refresh_tokento client-side JavaScript. - MUST set
sameSite: 'lax'onscalekit_sessionandoauth_statecookies; MUST NOT use'strict'— it drops the cookie on the OAuth callback redirect. - MUST validate the
nextredirect param as a relative path server-side; MUST NOT redirect to an arbitrary URL (open redirect).
Project structure
app/api/auth/
├── login/route.ts # GET — generates auth URL + sets CSRF state
├── callback/route.ts # GET — exchanges code, sets session cookie
├── logout/route.ts # POST — clears session, returns Scalekit logout URL
├── refresh/route.ts # POST — refreshes access token, updates session
└── validate/route.ts # Token validation endpoint
lib/
├── scalekit.ts # Singleton ScalekitClient + default scopes
├── cookies.ts # Session read/write/clear + OAuth state helpers
└── auth.ts # isAuthenticated(), getCurrentUser(), hasPermission()
Environment variables
SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.com
SCALEKIT_CLIENT_ID=your-client-id
SCALEKIT_CLIENT_SECRET=your-client-secret
SCALEKIT_REDIRECT_URI=http://localhost:3000/auth/callback
NEXT_PUBLIC_APP_URL=http://localhost:3000
SCALEKIT_SCOPES=openid profile email offline_access # optional, space-separated
SCALEKIT_REDIRECT_URI must exactly match the allowed callback URL in the Scalekit dashboard.
SDK client (lib/scalekit.ts)
Implement this module (singleton — always use getScalekitClient(), never instantiate ad hoc). Throws if env vars are missing. Full pattern is also in the reference repo.
// lib/scalekit.ts
import { ScalekitClient } from '@scalekit-sdk/node';
let client: ScalekitClient | null = null;
export function getScalekitClient() {
if (client) return client;
const env = process.env.SCALEKIT_ENVIRONMENT_URL;
const id = process.env.SCALEKIT_CLIENT_ID;
const secret = process.env.SCALEKIT_CLIENT_SECRET;
if (!env || !id || !secret) throw new Error('Missing SCALEKIT_* env vars');
client = new ScalekitClient(env, id, secret);
return client;
}
export function getDefaultScopes() {
return (process.env.SCALEKIT_SCOPES ?? 'openid profile email offline_access').split(/\s+/);
}
Session shape (lib/cookies.ts)
Session stored as JSON in a single scalekit_session HttpOnly cookie:
interface SessionData {
user: { sub, email, name, given_name, family_name, preferred_username };
tokens: { access_token, refresh_token, id_token, expires_at, expires_in };
roles?: string[];
permissions?: string[];
/** Short-lived lock for cross-tab refresh (see Token refresh race condition). */
refresh_in_progress?: boolean;
}
Key helpers:
getSession()— returnsSessionData | nullsetSession(data)— writes HttpOnly cookie; expires = tokenexpires_atclearSession()— deletes cookie (call on logout)isTokenExpired(session)— returns true if token expires within 5 minutesgetOAuthState()/setOAuthState(state)— CSRF state cookie, 10-min TTL- Cookie config:
httpOnly: true,securein production,sameSite: 'lax',path: '/'
Auth flow
Login (app/api/auth/login/route.ts — GET)
const state = crypto.randomBytes(32).toString('base64url');
await setOAuthState(state);
const authUrl = client.getAuthorizationUrl(redirectUri, { state, scopes: getDefaultScopes() });
return NextResponse.json({ authUrl });
Callback (app/api/auth/callback/route.ts — GET)
- Validate
stateparam against storedoauth_statecookie → redirect to/erroron mismatch clearOAuthState()client.authenticateWithCode(code, redirectUri)→authResponseclient.validateToken(authResponse.accessToken)→ extractroles,permissions- Permission claims checked in order:
permissions→https://scalekit.com/permissions→scalekit:permissions
- Permission claims checked in order:
- Name resolution priority:
user.name→claims.name→givenName + familyName→email→preferred_username→'User' setSession({ user, tokens, roles, permissions })- Redirect to the stored
nextpath if valid relative (/...), else/dashboard(see Deep link preservation)
Logout (app/api/auth/logout/route.ts — POST)
const logoutUrl = client.getLogoutUrl({
idTokenHint: session.tokens.id_token,
postLogoutRedirectUri: process.env.NEXT_PUBLIC_APP_URL,
});
await clearSession();
return NextResponse.json({ logoutUrl });
// Client receives logoutUrl and redirects
Token refresh (app/api/auth/refresh/route.ts — POST)
const session = await getSession();
if (!session?.tokens.refresh_token) return NextResponse.json({ error: 'no session' }, { status: 401 });
// Cross-tab lock (see Tactics → Token refresh race condition)
if (session.refresh_in_progress) return new NextResponse(null, { status: 204 });
await setSession({ ...session, refresh_in_progress: true });
try {
const refreshResponse = await client.refreshAccessToken(session.tokens.refresh_token);
const access_token = refreshResponse.accessToken;
const refresh_token = refreshResponse.refreshToken ?? session.tokens.refresh_token;
const expires_in = refreshResponse.expiresIn ?? 3600;
let expires_at = Math.floor(Date.now() / 1000) + expires_in;
try {
const { decodeJwt } = await import('jose');
const exp = decodeJwt(access_token).exp;
if (typeof exp === 'number') expires_at = exp;
} catch { /* keep expires_in fallback */ }
await setSession({
...session,
tokens: { ...session.tokens, access_token, refresh_token, expires_at, expires_in },
refresh_in_progress: false,
});
return NextResponse.json({ ok: true });
} catch {
await setSession({ ...session, refresh_in_progress: false });
return NextResponse.json({ error: 'refresh failed' }, { status: 401 });
}
Auth utilities (lib/auth.ts)
isAuthenticated() // → boolean (session exists)
getCurrentUser() // → session.user | null
getAccessToken() // → access_token string | null
hasPermission('read:data') // → validates token, checks permission claim
Protecting routes
For Server Components, call auth helpers directly:
import { isAuthenticated, getCurrentUser } from '@/lib/auth';
import { redirect } from 'next/navigation';
const authenticated = await isAuthenticated();
if (!authenticated) redirect('/login');
const user = await getCurrentUser();
For permission-gated pages:
import { hasPermission } from '@/lib/auth';
const allowed = await hasPermission('org:admin');
if (!allowed) redirect('/permission-denied');
Route map
| Route | Auth required |
|---|---|
/ |
No |
/login |
No |
/auth/callback |
No |
/dashboard |
Yes |
/sessions |
Yes |
/organization/settings |
Yes + permission |
/permission-denied |
No |
/error |
No |
Dependencies
# Session cookies are HttpOnly + server-managed — no client cookie library needed.
npm install @scalekit-sdk/node jose
Tactics
Edge middleware for route protection
Add middleware.ts at the project root to enforce auth before any Server Component renders:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const PROTECTED_PATHS = ['/dashboard', '/sessions', '/organization']
export function middleware(request: NextRequest) {
const session = request.cookies.get('scalekit_session')
const isProtected = PROTECTED_PATHS.some(p => request.nextUrl.pathname.startsWith(p))
if (isProtected && !session) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('next', request.nextUrl.pathname)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next|api|favicon).*)'],
}
Server Components should still call isAuthenticated() as a second layer.
Triggering login from a Client Component
/api/auth/login returns { authUrl } — never navigate there with router.push. OAuth requires a full page navigation:
const { authUrl } = await fetch('/api/auth/login').then(r => r.json())
window.location.href = authUrl // full navigation, not client-side route change
OIDC logout from the client
Logout returns { logoutUrl } — the client must navigate to it:
const { logoutUrl } = await fetch('/api/auth/logout', { method: 'POST' }).then(r => r.json())
window.location.href = logoutUrl // navigates to Scalekit end-session endpoint
Local session is already cleared; this step revokes the IdP session so the user isn't silently re-authenticated on next login.
Deep link preservation
In the login page, read ?next from search params and carry it through the state:
// app/login/page.tsx
const next = searchParams.get('next') || '/dashboard'
// Pass next to /api/auth/login as a query param, store in session before redirect
// In /api/auth/callback: redirect to stored next URL after setSession()
Validate next on the server: only allow relative paths (/...) to prevent open redirect.
SameSite=Lax — never Strict
The scalekit_session and oauth_state cookies must use sameSite: 'lax'. The OAuth callback is a cross-site redirect from Scalekit back to your app — 'strict' drops the cookie on that redirect, causing a CSRF state mismatch error every time.
Cache-Control: no-store on protected pages
Without this, the browser back button after logout serves a cached authenticated page:
// In a protected route handler or layout
export const dynamic = 'force-dynamic'
// Or explicitly in a route handler:
return new Response(html, {
headers: { 'Cache-Control': 'no-store' },
})
Token refresh race condition across tabs
Multiple browser tabs can simultaneously trigger token refresh with the same refresh token — most IdPs reject the second attempt. Mitigation in app/api/auth/refresh/route.ts:
getSession()— ifsession.refresh_in_progress === true, return 204 / skip refresh.setSession({ ...session, refresh_in_progress: true })before calling Scalekit.- Call
client.refreshAccessToken(...), thensetSession({ ...session, tokens: {...}, refresh_in_progress: false }). - On failure, clear
refresh_in_progressand force re-login.