Guardrails
- MUST choose storage by app type (ask first — see Inputs):
- Traditional / server-rendered web apps: store access and refresh tokens in HttpOnly cookies, with
Secureenabled in production. - SPAs: keep the access token in memory (send via
Authorization: Bearer); store the refresh token in an HttpOnly cookie or other secure storage — do not put the access token inlocalStorage. - Mobile: use secure platform storage for refresh; keep access tokens short-lived in memory when feasible.
- Traditional / server-rendered web apps: store access and refresh tokens in HttpOnly cookies, with
- MUST validate the access token on every protected request and transparently refresh it via the refresh token when expired.
- MUST NOT let a failed refresh silently continue the request; return 401 and force re-login.
- MUST revoke the corresponding session via the Scalekit session API on logout / "sign out this device".
Inputs to collect (ask before coding)
- App type: traditional server-rendered web app, SPA, mobile app, or hybrid.
- Framework: Express/Fastify/Next (Node), Flask/Django/FastAPI (Python), Gin/Fiber (Go), Spring Boot (Java), etc.
- Token storage plan:
- Cookie names (examples used below:
accessToken,refreshToken,idToken). - Cookie attributes actually used in the repo (Path, Domain, Secure, HttpOnly, SameSite).
- Cookie names (examples used below:
- Encryption approach already present (KMS, libsodium, AES-GCM, framework session store), or whether the app needs one introduced.
- Scalekit SDK/client availability and the exact methods used (validate, refresh, sessions list/revoke).
Non-negotiable security rules (defaults)
- Store access and refresh tokens separately.
- Use HttpOnly cookies for tokens in traditional web apps to reduce XSS exposure.
- Use
Securein production (HTTPS-only) and setSameSitetoLax(required for OAuth callback redirects to work correctly;Strictbreaks auth flows). - Scope cookies with
Pathto reduce exposure:- Access token cookie: scope to
/api(or your protected routes) when possible. - Refresh token cookie: scope to the refresh endpoint only (example
/auth/refresh).
- Access token cookie: scope to
- Rotate refresh tokens on each refresh if your Scalekit flow supports it (token rotation helps detect theft).
encrypt / decrypt helpers: Snippets below call encrypt(...) / decrypt(...) as placeholders for the app's own crypto (KMS, libsodium, AES-GCM, framework session store). They are not Scalekit SDK methods. If the project has no crypto helpers yet, introduce one before wiring cookies — never store raw refresh tokens in plaintext cookies when an encryption layer is already standard in the codebase.
Workflow (implementation sequence)
- Implement “store tokens” at login completion.
- Implement “verify + refresh” middleware that runs on every protected request.
- Implement a dedicated refresh endpoint (recommended even if middleware calls refresh internally).
- Add logout and remote session revocation if the product needs “sign out this device / sign out all devices”.
- Add a test checklist (cookie flags, refresh flow, failure modes).
1) Store session tokens securely
Cookie-based approach (traditional web apps)
Use encryption-in-cookie as an extra layer, then store:
- Access token in an HttpOnly cookie with short TTL.
- Refresh token in a separate HttpOnly cookie, ideally scoped to the refresh route.
- ID token in a place that remains available at runtime if needed for logout flows (cookie or local storage depending on your logout design).
Node.js (Express)
import cookieParser from "cookie-parser";
app.use(cookieParser());
// Example after successful authentication:
const { accessToken, expiresIn, refreshToken, idToken } = authResult;
// Encrypt before storing (implementation is app-specific)
const encAccess = encrypt(accessToken);
const encRefresh = encrypt(refreshToken);
// Access token: short-lived, cookie scoped
res.cookie("accessToken", encAccess, {
maxAge: (expiresIn - 60) * 1000, // clock-skew buffer
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/api",
});
// Refresh token: separate cookie, scoped to refresh endpoint
res.cookie("refreshToken", encRefresh, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/auth/refresh",
});
// Optional: ID token for logout (only if your logout needs it)
if (idToken) {
res.cookie("idToken", idToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
});
}
Python (Flask)
from flask import make_response
import os
# auth_result: access_token, expires_in, refresh_token, id_token (optional)
enc_access = encrypt(auth_result.access_token)
enc_refresh = encrypt(auth_result.refresh_token)
resp = make_response()
resp.set_cookie(
"accessToken",
enc_access,
max_age=auth_result.expires_in - 60,
httponly=True,
secure=os.environ.get("FLASK_ENV") == "production",
samesite="Lax",
path="/api",
)
resp.set_cookie(
"refreshToken",
enc_refresh,
httponly=True,
secure=os.environ.get("FLASK_ENV") == "production",
samesite="Lax",
path="/auth/refresh",
)
if getattr(auth_result, "id_token", None):
resp.set_cookie(
"idToken",
auth_result.id_token,
httponly=True,
secure=os.environ.get("FLASK_ENV") == "production",
samesite="Lax",
path="/",
)
Go (Gin)
// accessToken, refreshToken, expiresIn come from your auth completion result
encAccess := encrypt(accessToken)
encRefresh := encrypt(refreshToken)
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie("accessToken", encAccess, expiresIn-60, "/api", "", isProd(), true)
c.SetCookie("refreshToken", encRefresh, 0, "/auth/refresh", "", isProd(), true)
// Optional
if idToken != "" {
c.SetCookie("idToken", idToken, 0, "/", "", isProd(), true)
}
Java (Spring)
// Encrypt tokens before storing (implementation is app-specific)
String encAccess = encrypt(authResult.getAccessToken());
String encRefresh = encrypt(authResult.getRefreshToken());
Cookie access = new Cookie("accessToken", encAccess);
access.setMaxAge(authResult.getExpiresIn() - 60);
access.setHttpOnly(true);
access.setSecure(isProd());
access.setPath("/api");
response.addCookie(access);
// Ensure SameSite is applied (implementation depends on your framework version)
Cookie refresh = new Cookie("refreshToken", encRefresh);
refresh.setHttpOnly(true);
refresh.setSecure(isProd());
refresh.setPath("/auth/refresh");
response.addCookie(refresh);
SPA/mobile note (reduce CSRF exposure)
For SPAs and mobile apps (see Guardrails), prefer:
- Access token stored in memory and sent via
Authorization: Bearer <token>— not inlocalStorage. - Refresh token stored in an HttpOnly cookie (browser) or secure device storage (mobile). If using cookies in a browser SPA, configure CSRF protections explicitly.
SPA API validation (all languages): read the Bearer token from the Authorization header — not cookies. Cookie middleware in section 2 is for traditional web apps. Pattern:
| Runtime | Resolve access token |
|---|---|
| Node | req.headers.authorization?.replace(/^Bearer\s+/i, "") |
| Python | request.headers.get("Authorization", "").removeprefix("Bearer ").strip() |
| Go | strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ") |
| Java | strip "Bearer " from request.getHeader("Authorization") |
SPA refresh: call /auth/refresh (refresh cookie / secure store) → response body { accessToken, expiresIn } → client updates in-memory token. Do not set an access-token cookie for SPAs.
2) Validate access token on every request (and refresh transparently)
Behavior
- If access token is valid: proceed.
- If access token is expired and refresh token exists: refresh, rotate, rewrite cookies/headers, proceed.
- If refresh fails: return 401 and force re-login.
Node.js middleware (Express-style, cookie-based traditional web)
Cookie rewrite on refresh is for traditional web apps only. For SPAs, keep the access token in memory: refresh via a dedicated
/auth/refreshendpoint that returns{ accessToken, expiresIn }in the JSON body, update client memory, and re-sendAuthorization: Bearer— do not write the access token into a cookie.
// encrypt/decrypt = app-owned helpers (see note under security rules)
// Signature is app-defined (e.g. encrypt(plaintext) → ciphertext string).
export async function verifySession(req, res, next) {
// Traditional web path: read encrypted tokens from cookies.
const accessCookie = req.cookies?.accessToken;
const refreshCookie = req.cookies?.refreshToken;
if (!accessCookie) return res.status(401).json({ error: "Authentication required" });
try {
const accessToken = decrypt(accessCookie);
const isValid = await scalekit.validateAccessToken(accessToken);
if (isValid) return next();
// Not valid -> attempt refresh
if (!refreshCookie) {
return res.status(401).json({ error: "Session expired. Please sign in again." });
}
const refreshToken = decrypt(refreshCookie);
const authResult = await scalekit.refreshAccessToken(refreshToken);
res.cookie("accessToken", encrypt(authResult.accessToken), {
maxAge: (authResult.expiresIn - 60) * 1000,
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/api",
});
res.cookie("refreshToken", encrypt(authResult.refreshToken), {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/auth/refresh",
});
return next();
} catch (e) {
return res.status(401).json({ error: "Authentication failed" });
}
}
Python decorator (Flask)
from functools import wraps
from flask import request, jsonify, make_response
def verify_session(f):
@wraps(f)
def inner(*args, **kwargs):
access_cookie = request.cookies.get("accessToken")
refresh_cookie = request.cookies.get("refreshToken")
if not access_cookie:
return jsonify({"error": "Authentication required"}), 401
try:
access_token = decrypt(access_cookie)
is_valid = scalekit_client.validate_access_token(access_token)
if is_valid:
return f(*args, **kwargs)
if not refresh_cookie:
return jsonify({"error": "Session expired. Please sign in again."}), 401
refresh_token = decrypt(refresh_cookie)
auth_result = scalekit_client.refresh_access_token(refresh_token)
resp = make_response(f(*args, **kwargs))
resp.set_cookie("accessToken", encrypt(auth_result.access_token),
max_age=auth_result.expires_in - 60, httponly=True,
secure=is_prod(), samesite="Lax", path="/api")
resp.set_cookie("refreshToken", encrypt(auth_result.refresh_token),
httponly=True, secure=is_prod(), samesite="Lax", path="/auth/refresh")
return resp
except Exception:
return jsonify({"error": "Authentication failed"}), 401
return inner
Go middleware (Gin)
func VerifySession() gin.HandlerFunc {
return func(c *gin.Context) {
accessCookie, err := c.Cookie("accessToken")
if err != nil || accessCookie == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error":"Authentication required"})
c.Abort()
return
}
accessToken := decrypt(accessCookie)
isValid, err := scalekitClient.ValidateAccessToken(accessToken)
if err == nil && isValid {
c.Next()
return
}
refreshCookie, err := c.Cookie("refreshToken")
if err != nil || refreshCookie == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error":"Session expired. Please sign in again."})
c.Abort()
return
}
refreshToken := decrypt(refreshCookie)
authResult, err := scalekitClient.RefreshAccessToken(refreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error":"Session expired. Please sign in again."})
c.Abort()
return
}
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie("accessToken", encrypt(authResult.AccessToken), authResult.ExpiresIn-60, "/api", "", isProd(), true)
c.SetCookie("refreshToken", encrypt(authResult.RefreshToken), 0, "/auth/refresh", "", isProd(), true)
c.Next()
}
}
Java interceptor (Spring)
Implement HandlerInterceptor#preHandle (or a filter) to:
- Read cookies.
- Decrypt and validate access token.
- Refresh when invalid and refresh token exists.
- Rewrite cookies and allow the request to proceed. Return 401 on failure.
3) Configure session security and duration (dashboard-driven)
Session behavior should be adjustable without code changes (typical policy knobs):
- Absolute session timeout (max total session time).
- Idle session timeout (logout after inactivity).
- Access token lifetime (drives refresh frequency).
4) Manage sessions remotely (API/SDK)
Use Scalekit session APIs to implement:
- “View active sessions” in account settings.
- “Sign out this device” (revoke a single session).
- “Sign out all devices” (revoke all sessions for a user).
SDK method names vary slightly by language; the operations are the same: get session, list user sessions, revoke one, revoke all.
Node.js
const sessionDetails = await scalekit.session.getSession("ses_1234567890123456");
const userSessions = await scalekit.session.getUserSessions("usr_1234567890123456", {
pageSize: 10,
filter: { status: ["ACTIVE"] }
});
await scalekit.session.revokeSession("ses_1234567890123456");
await scalekit.session.revokeAllUserSessions("usr_1234567890123456");
Python
session_details = scalekit_client.session.get_session("ses_1234567890123456")
user_sessions = scalekit_client.session.get_user_sessions(
"usr_1234567890123456", page_size=10, filter={"status": ["ACTIVE"]}
)
scalekit_client.session.revoke_session("ses_1234567890123456")
scalekit_client.session.revoke_all_user_sessions("usr_1234567890123456")
Go / Java: same four operations on the session client — check the installed SDK for exact method names if they differ.
Testing checklist (must pass)
- Cookies are
HttpOnly,Secure(in prod), andSameSiteis set intentionally. - Cookie
Pathscoping works: refresh token cookie is only sent to/auth/refresh. - Protected routes reject missing/invalid access token with 401.
- Expired access token triggers refresh and continues the request without user interaction.
- Refresh failure forces re-login (401) and does not loop.
- Multi-device: remote revoke invalidates the targeted session(s) as expected.
Common failure modes
- Cookie deletion/overwrite doesn’t work due to mismatched Path/Domain.
- Refresh token accidentally sent to all endpoints (missing
Path=/auth/refresh). - Middleware refreshes but does not rotate tokens (misses theft detection benefits).
- SPA stores access token in localStorage (higher XSS risk) when memory storage was feasible.
When to switch skills
- Use
implementing-saaskitfor the initial auth setup that produces the tokens. - Use
implementing-access-controlfor RBAC checks on the validated session. - Use
production-readiness-saaskitto audit session security before launch.