Skip to content

auth.oauth.no-state-validation

OAuth callback handler reads state from the request but never

OAuthLint idAUTH-OAUTH-007
SeverityWARNING
LLM prevalenceHIGH
CWECWE-352
OWASPAPI1:2023
Languagesjavascript, typescript

Why this matters

OAuth callback handler reads state from the request but never compares it to a stored value. Sending state on the authorize call is half of the CSRF mitigation; verifying it on the callback is the other half.

Compare the received state to the value you stored before redirecting (session, signed cookie, or Redis). Reject the callback if it's missing or doesn't match.

❌ Vulnerable

ts
interface Req {
  query: { state?: string; code?: string };
  body: { state?: string };
}

export function badCallback(req: Req) {
  // ruleid: auth.oauth.no-state-validation
  const state = req.query.state;
  console.log(`received state ${state}`);
  return req.query.code;
}

export function badCallback2(req: Req) {
  // ruleid: auth.oauth.no-state-validation
  return req.body.state;
}

✅ Safe

ts
interface Req {
  query: { state?: string };
  session: { oauth_state?: string };
}

declare function verifyState(s?: string): boolean;

// ok: auth.oauth.no-state-validation
export function goodCallback(req: Req) {
  if (req.session.oauth_state !== req.query.state) {
    throw new Error('CSRF: state mismatch');
  }
  return true;
}

// ok: auth.oauth.no-state-validation -- reversed operand order is still validation
export function goodCallbackReversed(req: Req) {
  if (req.query.state !== req.session.oauth_state) {
    throw new Error('CSRF: state mismatch');
  }
  return true;
}

// ok: auth.oauth.no-state-validation -- validated via a helper
export function goodCallbackHelper(req: Req) {
  if (!verifyState(req.query.state)) {
    throw new Error('CSRF: state mismatch');
  }
  return true;
}

// ok: auth.oauth.no-state-validation -- state captured into a local, then validated
export function goodCallbackViaLocal(req: Req) {
  const state = req.query.state;
  if (req.session.oauth_state !== state) {
    throw new Error('CSRF: state mismatch');
  }
  return true;
}

// ok: auth.oauth.no-state-validation -- searchParams read into a local, then validated
export function goodCallbackSearchParams(url: URL, stored?: string) {
  const state = url.searchParams.get('state');
  if (!stored || stored !== state) {
    throw new Error('CSRF: state mismatch');
  }
  return true;
}

Suppressing this rule (when you really must)

ts
// oauthlint-disable-next-line auth.oauth.no-state-validation -- <reason>
thisLineWouldOtherwiseTriggerTheRule();

Disable directives are line-scoped by design — wholesale silencing of a rule across the codebase is intentionally not supported, because the next reviewer needs to see exactly which lines opted out.

References

Released under the MIT License · powered by Semgrep