Summary
The GitHub OAuth connection flow generates a state nonce but never validates it server-side during the callback flow.
The callback handler currently trusts the decoded userId from the client-controlled state payload without verifying whether the nonce was ever issued by the server.
This allows forged OAuth state payloads and enables forced account-linking attacks.
Affected File
apps/backend/src/routes/connect.ts
Root Cause
The /api/connect/github route generates:
and base64-encodes it into the OAuth state parameter.
However, during callback handling:
/api/connect/github/callback
the backend only decodes and structurally validates the payload:
if (
typeof decoded.userId !== 'string' ||
typeof decoded.nonce !== 'string'
) {
return null;
}
The nonce itself is never:
- stored server-side,
- cross-checked,
- expired,
- or invalidated.
A code comment already acknowledges the missing validation:
// In a real app, store this in Redis to cross-check in callback
Security Impact
This creates a CSRF-style OAuth account-linking vulnerability.
An attacker can:
- obtain a victim's user ID,
- forge a valid-looking OAuth
state,
- complete GitHub OAuth using their own GitHub account,
- inject the forged
state into the callback request.
The backend will then:
- trust the forged
userId,
- exchange the OAuth code,
- and store the attacker's GitHub token under the victim's account.
This can result in:
- forced GitHub account linking,
- unauthorized follow actions,
- incorrect OAuth identity association,
- and compromised social integration integrity.
Example Attack Flow
const forgedState = Buffer.from(
JSON.stringify({
userId: "<victim-user-id>",
nonce: "fake"
})
).toString("base64");
The attacker then:
- authenticates normally with GitHub,
- intercepts the callback request,
- replaces the
state parameter with the forged value.
The backend accepts the forged callback and associates the attacker's GitHub token with the victim account.
Proposed Fix
Implement proper server-side nonce validation.
Suggested approach:
During OAuth initiation
- generate a cryptographically secure nonce,
- store it in Redis:
oauth_nonce:{nonce} -> userId
with a short expiration (e.g. 5–10 minutes).
During callback
- look up the nonce,
- verify it matches the expected user,
- reject unknown/expired nonces,
- delete the nonce immediately after successful validation.
Additional recommendations:
- reject replayed nonces,
- fail closed on Redis lookup failure,
- add structured logging for invalid state attempts.
Acceptance Criteria
- OAuth nonce is persisted server-side before redirect.
- Callback validates nonce existence before trusting
userId.
- Nonce is deleted after successful use.
- Expired or forged states are rejected.
- Replay attacks are prevented.
- Existing OAuth flows continue working normally.
Security Severity
High
This is a realistic OAuth state-validation vulnerability affecting account-linking integrity.
Summary
The GitHub OAuth connection flow generates a
statenonce but never validates it server-side during the callback flow.The callback handler currently trusts the decoded
userIdfrom the client-controlledstatepayload without verifying whether the nonce was ever issued by the server.This allows forged OAuth state payloads and enables forced account-linking attacks.
Affected File
apps/backend/src/routes/connect.tsRoot Cause
The
/api/connect/githubroute generates:and base64-encodes it into the OAuth
stateparameter.However, during callback handling:
the backend only decodes and structurally validates the payload:
The nonce itself is never:
A code comment already acknowledges the missing validation:
// In a real app, store this in Redis to cross-check in callbackSecurity Impact
This creates a CSRF-style OAuth account-linking vulnerability.
An attacker can:
state,stateinto the callback request.The backend will then:
userId,This can result in:
Example Attack Flow
The attacker then:
stateparameter with the forged value.The backend accepts the forged callback and associates the attacker's GitHub token with the victim account.
Proposed Fix
Implement proper server-side nonce validation.
Suggested approach:
During OAuth initiation
oauth_nonce:{nonce} -> userIdwith a short expiration (e.g. 5–10 minutes).
During callback
Additional recommendations:
Acceptance Criteria
userId.Security Severity
High
This is a realistic OAuth state-validation vulnerability affecting account-linking integrity.