Best Practices

AI Rules for OAuth Integration

AI generates OAuth with client secrets in frontend code and no PKCE. Rules for Authorization Code flow, PKCE, state parameters, token exchange, and secure account linking.

7 min read·January 24, 2025

Client secret in frontend code, no state parameter, auto-merge on email — four vulnerabilities in one flow

Authorization Code + PKCE, state validation, minimal scopes, secure account linking

AI Implements OAuth from Stack Overflow Circa 2015

AI generates OAuth with: the Implicit flow (token in URL fragment — deprecated since 2019), client secret in frontend code (visible in browser DevTools), no state parameter (CSRF vulnerable), auto-merge on email match (account takeover vector), over-scoped permissions (requests read+write when only read is needed), and no token exchange validation (accepts any callback). Every pattern is a known vulnerability with a standardized fix.

Modern OAuth is: Authorization Code flow (code exchanged server-side), PKCE-protected (Proof Key for Code Exchange — even public clients are secure), state-validated (random state parameter prevents CSRF), carefully scoped (request minimal permissions), server-side token exchange (client secret never leaves the server), and safe account linking (verify ownership before merge). AI generates none of these.

These rules cover: Authorization Code + PKCE flow, state parameter validation, server-side token exchange, minimal scopes, and secure account linking strategies.

Rule 1: Authorization Code Flow with PKCE

The rule: 'Always use Authorization Code flow with PKCE. The flow: (1) generate a random code_verifier, (2) compute code_challenge = SHA256(code_verifier), (3) redirect to provider with code_challenge, (4) provider redirects back with authorization code, (5) exchange code + code_verifier for tokens server-side. PKCE prevents authorization code interception — even if an attacker captures the code, they cannot exchange it without the code_verifier.'

For public vs confidential clients: 'SPAs and mobile apps are public clients — they cannot securely store a client secret. PKCE was designed for public clients but is now recommended for all clients (RFC 9126). Confidential clients (server-side apps) use PKCE + client secret for defense in depth. Never use the Implicit flow — it was deprecated by the OAuth 2.0 Security Best Current Practice (RFC 9700).'

AI generates: window.location = 'provider/auth?response_type=token&client_id=...' — Implicit flow, token in the URL fragment, visible in browser history, referrer headers, and server logs. Authorization Code + PKCE: the token never appears in the URL, the code is useless without the verifier, and exchange happens server-side over HTTPS.

  • Authorization Code + PKCE — always, for all client types (public and confidential)
  • code_verifier (random 43-128 chars) + code_challenge (SHA256 hash) — generated per request
  • Server-side token exchange — code + code_verifier sent from your backend, not the browser
  • Never Implicit flow — deprecated, token exposed in URL fragment
  • PKCE prevents code interception — attacker cannot exchange stolen code without verifier
💡 PKCE for Everyone

PKCE was designed for public clients (SPAs, mobile) but RFC 9126 recommends it for all clients. Even confidential servers benefit: PKCE + client secret = defense in depth. One extra parameter (code_challenge) prevents an entire class of code interception attacks.

Rule 2: State Parameter for CSRF Protection

The rule: 'Generate a cryptographically random state parameter for every OAuth request. Store it in the session (server-side) or a secure httpOnly cookie. On callback, verify that the returned state matches the stored state. Reject the callback if state is missing or mismatched. This prevents CSRF attacks where an attacker tricks the user into completing an OAuth flow that links the attacker account.'

For the attack without state: 'Attacker starts OAuth flow with their own account, gets the authorization code, then tricks the victim into visiting the callback URL with the attacker code. The victim app links the attacker provider account to the victim user account. Now the attacker can log in as the victim using their own provider credentials. The state parameter breaks this — the callback state will not match the victim session.'

AI generates: redirect to provider with no state parameter, callback handler that accepts any response. The CSRF attack is trivial and gives the attacker full account access. One random string, stored in the session, verified on callback — three lines of code that prevent account takeover.

⚠️ No State = Account Takeover

Without the state parameter: attacker starts OAuth with their account, tricks victim into visiting the callback URL. Victim app links attacker provider to victim account. Attacker logs in as victim. One random string in the session prevents this entirely.

Rule 3: Server-Side Token Exchange and Minimal Scopes

The rule: 'Exchange the authorization code for tokens on your server, not in the browser. The token exchange requires the client secret (for confidential clients) — this must never be in frontend code. Send the code + code_verifier + client_secret from your backend to the provider token endpoint over HTTPS. Store the resulting tokens server-side or in httpOnly cookies.'

For scope minimization: 'Request the minimum scopes needed: openid profile email for social login (not repo, admin, or write access). Each additional scope is: a permission the user must approve (friction), data you must protect (liability), and an attack surface (if your tokens are stolen, the damage is limited to granted scopes). Review scopes quarterly — remove any that are no longer used.'

AI generates: scope: 'user repo admin' — requesting full repository and admin access for a login button. The user sees a scary permission dialog, conversion drops. If tokens are stolen, the attacker has admin access to all repos. Minimal scopes: less friction, less liability, less blast radius.

Rule 4: Secure Account Linking

The rule: 'Never auto-merge accounts based on email address alone. When a user signs in with a provider (Google) and an account with that email exists (from password signup), require verification of the existing account before linking. Options: (1) prompt for the existing account password, (2) send a verification email to confirm ownership, (3) require the user to link accounts from within the authenticated session.'

For the attack without verification: 'Attacker creates a Google account with victim-email@example.com (possible with some providers). Attacker signs in with OAuth. App finds existing account with that email and auto-merges. Attacker now has full access to the victim account — created through password signup, stolen through unverified OAuth linking. This is a well-documented account takeover pattern.'

AI generates: const user = await db.users.findOne({ email: profile.email }); if (user) linkAccount(user, providerAccount); — auto-merge with zero verification. The account takeover requires only knowing the victim email. Verification before linking adds one step for legitimate users and completely prevents this attack vector.

  • Never auto-merge on email — require verification of existing account ownership
  • Options: password prompt, verification email, or link from authenticated session
  • Allow multiple providers per account — but each link requires explicit user consent
  • Log all account linking events — who, which provider, when, from which IP
  • Allow unlinking providers — but require at least one auth method remains active
ℹ️ Email Is Not Identity

Attacker creates a Google account with victim@example.com, signs in via OAuth, app auto-merges on email match — attacker owns the victim account. Email alone is not proof of identity. Require verification before linking accounts from different auth methods.

Rule 5: OAuth Error Handling and Edge Cases

The rule: 'Handle every OAuth callback scenario: success (code present), user denied (error=access_denied), provider error (error=server_error), state mismatch (CSRF attempt), and expired code (too slow to exchange). Each scenario needs a specific user-facing response — not a generic error page. Log all failures with the error code and description for debugging.'

For token refresh: 'OAuth access tokens expire. Store the refresh token securely and use it to get new access tokens before they expire. Not all providers issue refresh tokens by default — Google requires access_type=offline and prompt=consent on the first authorization. If the refresh token is revoked (user removed your app from their account), redirect to re-authorization gracefully.'

AI generates: callback handler that only handles the success case. User denies permission — unhandled. Provider returns an error — 500. State mismatch — ignored. Every edge case is a user stuck on an error page or, worse, a security vulnerability. Five conditionals in the callback handler cover all scenarios.

Complete OAuth Integration Rules Template

Consolidated rules for OAuth integration.

  • Authorization Code + PKCE — always, all client types, never Implicit flow
  • Cryptographic state parameter — stored server-side, verified on callback
  • Server-side token exchange — client secret never in frontend code
  • Minimal scopes — openid profile email for login, nothing more unless required
  • No auto-merge on email — verify existing account ownership before linking
  • Handle all callback scenarios — denied, error, expired, state mismatch
  • Store refresh tokens securely — handle revocation and re-authorization gracefully
  • Use established libraries (NextAuth, Passport.js) — never implement OAuth from scratch