DEV Community

Cover image for Stop Blindly Trusting Passport.js: How to Implement Secure OAuth CSRF Protection Manually
Pau Dang
Pau Dang

Posted on

Stop Blindly Trusting Passport.js: How to Implement Secure OAuth CSRF Protection Manually

OAuth 2.0 is the backbone of modern authentication. But many developers treat it as a "set it and forget it" feature by using libraries like Passport.js. While these libraries are great, they often hide the critical security handshake happening under the hood—leaving your app vulnerable to OAuth CSRF attacks.

In this post, I'll show you how we implemented a Zero-Trust Social Login flow in nodejs-quickstart-structure v2.2.1 using plain Node.js and Axios.

The Attack: How a Hacker Steals Your Identity

Most developers know about standard CSRF (Cross-Site Request Forgery). OAuth CSRF is a specific variant where an attacker tricks a victim into linking the attacker's social account to the victim's application account.

Here is exactly how the attack happens:

  1. The Attacker's Setup: A hacker visits your site and clicks "Link Google." They log into their own Google account, but when Google redirects them back to your site, they stop and copy the code from the URL.
  2. The Phish: The hacker sends a victim a link: https://your-app.com/api/auth/google/callback?code=HACKER_CODE.
  3. The Click: The victim (already logged into your app) clicks the link.
  4. The Link: Your server receives the HACKER_CODE, validates it with Google, and sees it's a valid account. Since the victim is the one who sent the request, your server links the hacker's Google ID to the victim's user profile.
  5. The Takeover: The hacker can now simply click "Login with Google" on your site and they are instantly logged in as the victim.

The result? A quiet, total account takeover.

The Attack Flow

The Solution: The state Parameter

The OAuth2 spec provides a state parameter specifically to prevent this. Here is how it looks:

The Secure Flow

Step 1: Generate and Store a Secure State

When the user clicks "Login with Google," don't just redirect them. Generate a random string, store it in a secure cookie, and pass it to the provider.

// authController.js
async googleLogin(req, res) {
  const state = crypto.randomBytes(16).toString('hex');

  // Store state in an HttpOnly, SameSite=Lax cookie
  res.cookie('oauth_state', state, { 
    httpOnly: true, 
    secure: true, // Always use HTTPS in production
    sameSite: 'lax',
    maxAge: 10 * 60 * 1000 // Valid for 10 minutes
  });

  const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` + 
    new URLSearchParams({
      client_id: process.env.GOOGLE_CLIENT_ID,
      redirect_uri: process.env.GOOGLE_CALLBACK_URL,
      response_type: 'code',
      scope: 'profile email',
      state: state // <--- The magic happens here
    }).toString();

  res.redirect(authUrl);
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Verify the State on Callback

When Google redirects the user back to your site, the first thing you must do is check if the state in the URL matches the state in your cookie.

async googleCallback(req, res) {
  const { code, state } = req.query;
  const savedState = req.cookies?.oauth_state;

  // Always clear the cookie immediately to prevent reuse
  res.clearCookie('oauth_state');

  if (!state || state !== savedState) {
    return res.status(403).send('Security Alert: State mismatch detected!');
  }

  // Now it's safe to exchange the code for a token
  const tokenResponse = await axios.post('https://oauth2.googleapis.com/token', {
    code,
    client_id: process.env.GOOGLE_CLIENT_ID,
    client_secret: process.env.GOOGLE_CLIENT_SECRET,
    redirect_uri: process.env.GOOGLE_CALLBACK_URL,
    grant_type: 'authorization_code'
  });

  // Handle user data...
}
Enter fullscreen mode Exit fullscreen mode

Why This Manual Approach Wins

By ditching "black-box" libraries and using a deterministic flow with Axios, you get:

  1. Full Auditability: You can log exactly what was sent and received.
  2. Explicit Security: You can't "forget" to validate the state because you are writing the logic yourself.
  3. Leaner App: No need for heavy middleware if you only need social login.

Want a Battle-Tested Template?

Implementing this correctly across every project is tedious. That's why I built the Node.js Quickstart Generator.

If you want to see this implementation in action, check out these resources:

If you found this helpful, check out the repo and give it a ⭐!

GitHub Repo: nodejs-quickstart-structure

Top comments (0)