MCP server OpenID Connect security
MCP server OpenID Connect security — nonce validation, state CSRF, and token binding
OpenID Connect extends OAuth 2.0 with ID tokens, discovery, and session management — but each extension introduces attack surfaces that OAuth alone does not have. MCP servers that implement OIDC login flows are exposed to four vulnerability classes that basic OAuth 2.0 PKCE does not address: nonce replay that allows session hijacking after a token is stolen, state parameter CSRF that lets an attacker complete an authentication flow as themselves in the victim's browser, hybrid flow token substitution that downgrades the ID token guarantee, and open redirect via post_logout_redirect_uri. Each class has a correct Node.js mitigation.
Pattern 1: Missing nonce validation enables replay attacks
The OIDC nonce parameter is a random value you include in the authorization request that the identity provider echoes back verbatim in the nonce claim of the ID token. Its purpose is to bind the ID token to a specific authentication session: if an attacker steals an ID token and replays it in a different session, the nonce won't match the value stored for that session and the replay fails. Many MCP server OIDC integrations request a nonce but never actually validate the claim in the returned ID token.
// WRONG: nonce included in request but never validated in the returned ID token
import { generators } from 'openid-client';
app.get('/auth/login', (req, res) => {
const nonce = generators.nonce();
req.session.nonce = nonce;
const url = client.authorizationUrl({ scope: 'openid profile', nonce });
res.redirect(url);
});
app.get('/auth/callback', async (req, res) => {
const params = client.callbackParams(req);
const tokenSet = await client.callback(redirectUri, params, {
state: req.session.state,
// WRONG: nonce check is missing — the token is accepted even if nonce
// doesn't match, enabling replay of stolen tokens
});
req.session.user = tokenSet.claims();
res.redirect('/');
});
// RIGHT: validate nonce on every callback before accepting the ID token
app.get('/auth/callback', async (req, res) => {
const params = client.callbackParams(req);
const tokenSet = await client.callback(redirectUri, params, {
state: req.session.state,
nonce: req.session.nonce, // RIGHT: openid-client validates nonce claim
response_type: 'code',
});
// openid-client throws if nonce in ID token !== req.session.nonce
// Invalidate the nonce after use — it's single-use
delete req.session.nonce;
delete req.session.state;
req.session.user = tokenSet.claims();
res.redirect('/');
});
Pattern 2: State parameter CSRF
The OAuth state parameter doubles as a CSRF protection mechanism for the authorization flow. You generate a random value, store it server-side, include it in the redirect to the identity provider, and verify that the value the provider echoes back matches what you stored. Without this check, an attacker can craft an authorization request, complete the authentication as themselves, and then trick a victim into consuming the attacker's authorization code — logging the victim into the attacker's account (login CSRF).
// WRONG: state not generated, not stored, not verified
app.get('/auth/login', (req, res) => {
const url = client.authorizationUrl({ scope: 'openid profile' }); // no state
res.redirect(url);
});
app.get('/auth/callback', async (req, res) => {
const params = client.callbackParams(req);
const tokenSet = await client.callback(redirectUri, params); // no state check
req.session.user = tokenSet.claims();
res.redirect('/');
});
// RIGHT: cryptographic state value, stored server-side, verified on callback
import { generators } from 'openid-client';
app.get('/auth/login', (req, res) => {
// RIGHT: 256-bit random state, stored in the server session (not a cookie)
const state = generators.state(); // 32 random bytes, base64url-encoded
req.session.oauthState = state;
req.session.oauthInitiatedAt = Date.now();
const url = client.authorizationUrl({
scope: 'openid profile email',
state,
nonce: (req.session.oauthNonce = generators.nonce()),
});
res.redirect(url);
});
app.get('/auth/callback', async (req, res) => {
// Enforce a timeout — flows older than 10 min are rejected (prevents state fixation)
if (!req.session.oauthState || Date.now() - req.session.oauthInitiatedAt > 600_000) {
return res.status(400).send('Authentication session expired');
}
const params = client.callbackParams(req);
const tokenSet = await client.callback(redirectUri, params, {
state: req.session.oauthState, // throws if state doesn't match
nonce: req.session.oauthNonce,
});
delete req.session.oauthState;
delete req.session.oauthNonce;
delete req.session.oauthInitiatedAt;
req.session.user = tokenSet.claims();
res.redirect('/');
});
Pattern 3: Hybrid flow token substitution via missing c_hash/at_hash
OIDC hybrid flows (response_type code id_token or code token id_token) return both an authorization code and an ID token in the fragment. The identity provider includes c_hash (hash of the authorization code) and at_hash (hash of the access token) in the ID token to cryptographically bind the code and token to the ID token. If your MCP server skips hash validation, an attacker who intercepts the authorization code (via a Referer header, shared fragment storage, or a malicious JavaScript on the page) can substitute a different authorization code and exchange it for tokens bound to a different user.
// RIGHT: validate c_hash and at_hash in the ID token before using the code
// openid-client does this automatically when you call callbackParams + callback
// with the correct checks option, but verify your library version does too
const tokenSet = await client.callback(
redirectUri,
params,
{
state: req.session.oauthState,
nonce: req.session.oauthNonce,
response_type: 'code id_token', // tells the library to check c_hash
}
);
// If params.code doesn't hash to tokenSet.id_token.c_hash, callback() throws:
// RPError: c_hash mismatch
Pattern 4: post_logout_redirect_uri open redirect
The OIDC Session Management specification allows clients to pass a post_logout_redirect_uri to the end session endpoint to specify where the user is redirected after logout. If your MCP server proxies this URI from a query parameter without validating it against the registered redirect URIs, an attacker can craft a logout link that redirects the user to a phishing page after logging them out.
// WRONG: post_logout_redirect_uri passed through from query param without validation
app.get('/auth/logout', (req, res) => {
const endSessionUrl = client.endSessionUrl({
post_logout_redirect_uri: req.query.returnTo, // attacker controls this!
id_token_hint: req.session.idToken,
});
req.session.destroy();
res.redirect(endSessionUrl);
});
// RIGHT: allowlist against registered redirect URIs in OIDC client config
const ALLOWED_POST_LOGOUT_URIS = new Set([
'https://app.example.com/',
'https://app.example.com/login',
]);
app.get('/auth/logout', (req, res) => {
const rawUri = req.query.returnTo ?? 'https://app.example.com/';
const postLogoutUri = ALLOWED_POST_LOGOUT_URIS.has(rawUri)
? rawUri
: 'https://app.example.com/'; // fall back to home on unrecognized URI
const endSessionUrl = client.endSessionUrl({
post_logout_redirect_uri: postLogoutUri,
id_token_hint: req.session.idToken,
});
req.session.destroy();
res.redirect(endSessionUrl);
});
All four patterns are detectable in static analysis. SkillAudit checks for client.callback() calls missing the nonce check, authorization URL generation without a state parameter, and query-parameter-sourced post_logout URIs without an allowlist guard. For a full review of your MCP server's OIDC implementation — including token storage, silent refresh, and session binding — run a SkillAudit scan.
Related: OAuth 2.0 security patterns, JWT validation, JWT algorithm confusion attacks.