Claude skill session management security
MCP skills that expose an HTTP interface — a dashboard, an OAuth callback handler, a multi-turn conversation state store — frequently reach for express-session to maintain user state between requests. Session management in this context introduces four distinct failure modes: using a weak or default session secret that enables HMAC forgery, failing to regenerate the session ID after authentication or privilege escalation (enabling session fixation), issuing session cookies without the Secure flag (enabling interception over plain HTTP), and issuing cookies without SameSite=Strict (enabling CSRF via cross-origin form submissions). SA-SKL-SES captures these four patterns with detection algorithms applicable to MCP skill codebases using Node.js session middleware.
Session management vulnerabilities are not unique to MCP skills, but the MCP deployment model creates a specific risk profile. Many skills are written by indie developers who copy a express-session quickstart, deploy behind Caddy or nginx on a personal VPS, and never revisit the session configuration. The quickstart example invariably uses secret: 'keyboard cat' or secret: 'secret' — values that appear in every OWASP training article and are the first entries in any session-secret wordlist. Unlike a production web application that might have a security review before launch, a community MCP skill goes from npm package to user install in minutes. Each of the four SA-SKL-SES patterns is individually severe; together, a skill exhibiting all four allows an attacker to forge arbitrary session tokens, fix a victim’s session before login, intercept cookies over HTTP, and trigger state changes via cross-site requests.
SA-SKL-SES-001: session secret is an insecure default value brute-forceable by HMAC collision
The express-session middleware signs session IDs using an HMAC-SHA256 keyed by the secret configuration value. If the secret is a well-known string such as 'keyboard cat', 'secret', 'mysecret', or any short dictionary word, an attacker can forge a valid signed session ID for any session ID value they choose. The HMAC signature is what express-session uses to verify that a session cookie was issued by this server and not tampered with. With a known secret, the signature verification becomes trivially bypassable: the attacker constructs a session ID for a victim user’s known session identifier, computes the HMAC with the known secret, and presents the forged cookie to the server. The server considers it authentic. The fix is a cryptographically random secret of at least 64 bytes generated at process start with crypto.randomBytes(64).toString('hex') and stored in an environment variable, never hardcoded in source code.
// VULNERABLE: session secret is a well-known default value
import session from 'express-session';
import express from 'express';
const app = express();
app.use(session({
secret: 'keyboard cat', // BUG: appears in every express-session tutorial
// OR: 'secret', 'mysecret', 'changeme', 'password', any short string
resave: false,
saveUninitialized: true,
}));
// Attack: HMAC-SHA256('keyboard cat', sessionId) = known signature
// Attacker forges cookie: s%3A<victim_session_id>.<HMAC('keyboard cat', victim_id)>
// express-session verifies signature: matches → session considered authentic
// ALSO VULNERABLE: secret committed to source code (even if long)
app.use(session({
secret: 'a7f3c9d2b8e4f1a6c3d9e2f7b4a1c8d5e3f9a2b7c4d1e8f5a3b6c9d2e7f4a1b8',
// BUG: any committed secret becomes public when the repo is published
}));
// SECURE: cryptographically random secret from environment variable
import crypto from 'crypto';
// Generate at server startup — NOT at process.env read time (below)
// Validate at startup to fail fast if secret is missing or too short
const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret || sessionSecret.length < 64) {
throw new Error(
'SESSION_SECRET must be at least 64 characters. ' +
'Generate one: node -e "console.log(require(\'crypto\').randomBytes(64).toString(\'hex\'))"'
);
}
app.use(session({
secret: sessionSecret, // 128 hex chars = 64 random bytes = 512 bits entropy
resave: false,
saveUninitialized: false, // don't create session until something is stored
cookie: {
secure: true, // HTTPS only (pairs with SES-003)
httpOnly: true, // not accessible via document.cookie
sameSite: 'strict', // no cross-site requests (pairs with SES-004)
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
}));
// DETECTION (SkillAudit):
// session({ secret: <string_literal> }) — any hardcoded string regardless of content
// session({ secret: process.env.X }) where X is not validated for minimum length
const weakSecret = searchAST(
'session({ secret: <string_literal> }) OR ' +
'session({ secret: process.env.* }) WITHOUT length_check'
);
if (weakSecret) → CRITICAL // SA-SKL-SES-001
The session secret is the root signing key: a compromised or guessable secret allows unlimited session forgery for any session ID the attacker knows or can enumerate. Treat it with the same discipline as a database password: generate it with crypto.randomBytes(64), store it in an environment variable or secrets manager, and validate its length at startup. Rotate it when any team member with access leaves, which invalidates all existing sessions.
express-session configured with a string literal secret (default value, short string, or any hardcoded value); HMAC-SHA256 signature forgeable with known key; attacker constructs valid signed session cookie for any session ID; arbitrary session impersonation. Detection: session({ secret: <string_literal> }) in source → CRITICAL.SA-SKL-SES-002: session ID not regenerated after privilege escalation enables session fixation
Session fixation is an attack in which the attacker plants a known session ID in the victim’s browser before the victim authenticates, then waits for the victim to log in. If the server does not regenerate the session ID on authentication, the session that now belongs to the authenticated victim still uses the attacker-controlled ID. The attacker presents the same ID to the server and is granted access to the victim’s authenticated session. In MCP skills, the fixation vector is typically an open redirect that injects a Set-Cookie header with the attacker’s chosen session ID, or a skill endpoint that accepts a session ID as a query parameter and writes it into a cookie. The countermeasure is unconditional: call req.session.regenerate() before writing any authentication state to the session after a login or privilege-level change. The regenerate call creates a new session with a new ID and copies the old session data into it, then deletes the old session from the store. The user’s pre-login session ID — whatever the attacker may have planted — becomes invalid.
// VULNERABLE: session ID not regenerated after login
import express from 'express';
import session from 'express-session';
app.post('/login', async (req, res) => {
const user = await authenticateUser(req.body.username, req.body.password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// BUG: session ID remains the same as before login
// Attacker pre-set session ID to 'attacker-known-id' via Set-Cookie injection
// After victim logs in, attacker presents 'attacker-known-id' cookie
// Server grants access to victim's authenticated session
req.session.userId = user.id;
req.session.role = user.role;
res.json({ success: true });
});
// ALSO VULNERABLE: privilege escalation without regeneration
app.post('/elevate', async (req, res) => {
const totp = await verifyTOTP(req.body.code, req.session.userId);
if (totp) {
// BUG: session ID preserved through privilege elevation
req.session.elevated = true; // elevated privileges on the same (possibly fixed) session
res.json({ elevated: true });
}
});
// SECURE: regenerate session ID before writing authentication state
app.post('/login', async (req, res) => {
const user = await authenticateUser(req.body.username, req.body.password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Regenerate BEFORE writing any auth state — new session ID, same store
await new Promise<void>((resolve, reject) => {
req.session.regenerate((err) => {
if (err) reject(err); else resolve();
});
});
// Now safe to write authentication state to the fresh session
req.session.userId = user.id;
req.session.role = user.role;
req.session.authenticatedAt = Date.now();
// Save the new session explicitly to ensure it's persisted before response
await new Promise<void>((resolve, reject) => {
req.session.save((err) => {
if (err) reject(err); else resolve();
});
});
res.json({ success: true });
});
// Same pattern for privilege escalation:
app.post('/elevate', async (req, res) => {
const totp = await verifyTOTP(req.body.code, req.session.userId);
if (totp) {
const userId = req.session.userId; // save before regenerate clears session
await promisify(req.session.regenerate.bind(req.session))();
req.session.userId = userId;
req.session.elevated = true;
await promisify(req.session.save.bind(req.session))();
res.json({ elevated: true });
}
});
// DETECTION (SkillAudit):
// req.session.userId = * OR req.session.role = * WITHOUT preceding req.session.regenerate()
const noRegen = searchAST(
'req.session.userId = * WITHOUT req.session.regenerate(*) in same handler'
);
if (noRegen) → HIGH // SA-SKL-SES-002
Regenerate before writing, not after: calling req.session.regenerate() after writing authentication state to the session creates a race condition where the old session briefly holds the user’s identity. The correct order is: verify credentials → regenerate → write identity to new session → save. Copy any pre-login session data you want to preserve (shopping cart, CSRF token) explicitly after regenerate, before writing auth state.
req.session.userId = user.id written without preceding req.session.regenerate(); attacker-planted session ID persists through login; attacker presents same ID after victim authenticates; impersonates authenticated user. Detection: auth state written to session without req.session.regenerate() → HIGH.SA-SKL-SES-003: session cookie without Secure flag transmitted over HTTP in non-TLS contexts
The Secure cookie flag instructs the browser to only transmit the cookie over HTTPS connections, never over plain HTTP. Without this flag, the session cookie is included in HTTP requests to the same domain, including requests made over an unencrypted connection on a local network, a misconfigured CDN that accepts both HTTP and HTTPS, or an internal development server inadvertently exposed on a non-TLS port. In MCP skill deployments, this risk is acute: skills are often developed with a local HTTP server (http://localhost:3000) and deployed behind a TLS-terminating reverse proxy (Caddy, nginx) without auditing whether all session cookies are marked Secure. A user connecting over a coffee shop WiFi to a skill’s HTTPS endpoint has their session cookie sent over HTTP if the proxy handles a plain-HTTP pre-redirect request that includes the cookie before the redirect fires — or if the browser has a cached HTTP resource on the same domain. The fix is to set cookie.secure: true unconditionally in production, and to enforce HTTPS-only access at the reverse proxy level.
// VULNERABLE: session cookie without Secure flag
import session from 'express-session';
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
// BUG: secure flag absent — cookie transmitted over HTTP
// sameSite: 'strict', // also missing
maxAge: 24 * 60 * 60 * 1000,
},
}));
// Risk scenario:
// 1. User on coffee shop WiFi visits https://skill.example.com
// 2. Browser also makes request to http://skill.example.com (from cached HTTP link)
// 3. Browser includes session cookie in HTTP request — no Secure flag prevents this
// 4. Network observer (ARP-spoofed AP) reads session cookie from plaintext HTTP traffic
// 5. Attacker replays cookie to HTTPS endpoint — session hijack
// ALSO VULNERABLE: environment-conditional Secure flag skipped in dev
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: {
httpOnly: true,
// BUG: developer skips Secure flag in development, forgets to set in prod
secure: process.env.NODE_ENV === 'production' ? true : false,
// ^ This pattern silently deploys without Secure if NODE_ENV is unset
},
}));
// SECURE: Secure flag set unconditionally; separate config for local dev
const isProduction = process.env.NODE_ENV === 'production';
// In production, fail startup if not HTTPS
if (isProduction && !process.env.TRUST_PROXY) {
console.warn('WARNING: Set TRUST_PROXY=1 if behind a TLS-terminating proxy');
}
// Trust proxy headers (X-Forwarded-Proto) when behind Caddy/nginx
app.set('trust proxy', 1);
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: isProduction, // HTTP in local dev only; HTTPS in all deployed envs
httpOnly: true,
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000,
},
}));
// Additionally: HSTS header to prevent HTTP downgrade
app.use((req, res, next) => {
if (isProduction) {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
next();
});
// DETECTION (SkillAudit):
// session({ cookie: { secure: false } }) OR session({ cookie: {} }) (secure absent/default)
const noSecure = searchAST(
'session({ cookie: * }) WITHOUT cookie.secure = true'
);
if (noSecure) → HIGH // SA-SKL-SES-003
trust proxy is required when behind a reverse proxy: without app.set('trust proxy', 1), Express does not trust the X-Forwarded-Proto: https header set by Caddy or nginx. express-session then sees the request as HTTP (it arrived at Express over the internal HTTP connection from the proxy) and refuses to set a Secure cookie — or sets it but the browser never returns it because the same-origin HTTPS request includes no Secure cookies. Set trust proxy to exactly the number of proxy hops in your stack.
express-session cookie configured without secure: true; session cookie transmitted over plain HTTP connections; network observer intercepts cookie on unencrypted HTTP request (HTTP/HTTPS mixed content, pre-redirect request, internal non-TLS route); session hijack via cookie replay. Detection: session({ cookie: {} }) without secure: true → HIGH.SA-SKL-SES-004: session cookie without SameSite=Strict enables CSRF via cross-site form submission
The SameSite cookie attribute controls whether the browser includes the cookie in cross-site requests. Without SameSite=Strict (or at minimum SameSite=Lax for GET-only safe operations), a POST form on an attacker-controlled page that targets a skill’s state-changing endpoint will include the victim’s session cookie. This is classic Cross-Site Request Forgery (CSRF). In the MCP skill context, state-changing endpoints include: deleting an audit result, publishing a configuration change, transferring account ownership, or triggering an expensive LLM call on the victim’s quota. The default SameSite value differs by browser: modern browsers default to Lax for new cookies, but cookies set without an explicit SameSite attribute may be treated as None in some contexts or by browsers that have not yet adopted the new default. Explicit SameSite=Strict is the only reliable defense.
// VULNERABLE: session cookie without SameSite attribute
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: {
secure: true,
httpOnly: true,
// BUG: SameSite absent — browser default varies; may allow cross-site inclusion
maxAge: 24 * 60 * 60 * 1000,
},
}));
// Attack (CSRF on state-changing endpoint):
// Attacker's page at evil.example.com:
//
// <form action="https://skill.example.com/api/delete-audit" method="POST">
// <input type="hidden" name="auditId" value="victim-audit-123">
// </form>
// <script>document.forms[0].submit();</script>
//
// Without SameSite=Strict: browser includes session cookie in the cross-site POST
// Server sees authenticated request → deletes the victim's audit result
// ALSO VULNERABLE: SameSite=None without Secure (browser rejects this combination)
app.use(session({
cookie: {
secure: false,
sameSite: 'none', // BUG: SameSite=None requires Secure; browsers reject otherwise
},
}));
// SECURE: SameSite=Strict blocks all cross-site cookie inclusion
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true,
httpOnly: true,
sameSite: 'strict', // cookie never sent on cross-site requests (GET or POST)
maxAge: 24 * 60 * 60 * 1000,
},
}));
// Note: SameSite=Strict breaks OAuth flows where the provider redirects back
// to your domain — the session cookie is absent on the redirect because it's cross-site.
// For OAuth callbacks, use Lax + a CSRF token in the state parameter instead:
app.use(session({
cookie: {
secure: true,
httpOnly: true,
sameSite: 'lax', // allows top-level GET navigations (OAuth redirects)
maxAge: 24 * 60 * 60 * 1000,
},
}));
// Then validate the OAuth state parameter against a value stored in the session
// before the cross-site redirect began — this is a CSRF token at the OAuth layer.
// DETECTION (SkillAudit):
// session({ cookie: * }) without sameSite: 'strict' or sameSite: 'lax'
const noSameSite = searchAST(
'session({ cookie: * }) WITHOUT cookie.sameSite in ["strict", "lax"]'
);
if (noSameSite) → MEDIUM // SA-SKL-SES-004
SameSite=Strict vs Lax for OAuth skills: if the skill uses OAuth and receives a redirect from the identity provider, the redirect is a cross-site navigation and the session cookie is absent under SameSite=Strict. Use Lax (which allows top-level GET navigations) and pair it with an OAuth state parameter that includes a session-bound nonce. Verify the nonce matches when the redirect arrives. This gives CSRF protection for state-changing POST endpoints while keeping OAuth redirects functional.
sameSite: 'strict' or 'lax'; cross-site POST form on attacker’s page includes victim’s session cookie; state-changing endpoints (delete, publish, transfer, trigger) invoked without victim’s awareness; CSRF. Detection: session({ cookie: {} }) without explicit sameSite → MEDIUM.Remediation summary
| Finding | Severity | Fix |
|---|---|---|
| SES-001: Insecure session secret | CRITICAL | Generate secret with crypto.randomBytes(64).toString('hex'); store in SESSION_SECRET env var; validate at startup that it is ≥64 characters; never hardcode in source |
| SES-002: Session fixation | HIGH | Call req.session.regenerate() before writing any authentication state; copy pre-login data needed post-login explicitly; call req.session.save() after writing auth state |
| SES-003: Missing Secure flag | HIGH | Set cookie.secure: true in all deployed environments; set app.set('trust proxy', 1) when behind Caddy/nginx; add Strict-Transport-Security header |
| SES-004: Missing SameSite flag | MEDIUM | Set cookie.sameSite: 'strict' for non-OAuth skills; use 'lax' + OAuth state nonce for skills with identity provider redirects |
SkillAudit detects all four SA-SKL-SES-001 through SES-004 session management patterns through static analysis of Express session middleware configuration and authentication route handlers. For related authentication patterns in MCP skills, see SA-SKL-AUT OAuth flow security and SA-SKL-WBK webhook callback security. See the complete SA-SKL attack surface reference for the full vulnerability taxonomy.
Scan your Claude skill for session management vulnerabilities
SkillAudit detects insecure session secrets, missing session ID regeneration, absent Secure and SameSite cookie flags across the full SA-SKL-SES family in 60 seconds.
Get your free audit