All posts
jwtgoogle-oauthexpress.jsmongodbcookie-sessions

Building JWT + Google OAuth Auth System From Scratch

How I built a full auth system with admin/user roles, Google OAuth, and secure cookie-based sessions — architecture decisions, key challenges, and what I'd do differently.

SR

Suhail Roushan

May 9, 2026

·
4 min read

I built a secure, production-ready authentication system from scratch using JWT, Google OAuth, and Express.js. This project demonstrates how to properly handle user sessions, role-based access, and third-party logins without relying on bloated libraries. You can see the final implementation on my portfolio at suhailroushan.com.

Architecture Overview

The system separates concerns into distinct layers: a stateless JWT service, a stateful OAuth callback handler, and a persistent session manager using HTTP-only cookies. The core flow is that a user authenticates via Google, our backend verifies the identity token, creates a local user profile, and then issues our own short-lived JWT for subsequent API requests.

Here’s how the data flows:

flowchart TD
    A[User clicks Login] --> B[Redirect to Google OAuth]
    B --> C{Google Consent Screen}
    C --> D[Google redirects to /auth/google/callback]
    D --> E[Backend validates ID Token]
    E --> F[Find or create user in MongoDB]
    F --> G[Generate our own JWT]
    G --> H[Set JWT in HttpOnly Cookie]
    H --> I[Redirect to frontend]
    I --> J[Frontend calls API with cookie]
    J --> K[Middleware verifies JWT]
    K --> L[Access protected route]

Key Technical Decisions

I chose to store the JWT in an HttpOnly, Secure, SameSite=Strict cookie instead of localStorage. This prevents XSS attacks from stealing the token, as JavaScript cannot access it. The cookie is set server-side after OAuth validation.

// In your OAuth callback handler, after user creation/validation
const token = jwt.sign(
  { userId: user._id, role: user.role },
  process.env.JWT_SECRET!,
  { expiresIn: '15m' } // Short-lived access token
);

res.cookie('auth_token', token, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'strict',
  maxAge: 15 * 60 * 1000 // 15 minutes in milliseconds
});

For role-based access, I built a middleware factory that accepts an array of allowed roles. This keeps route protection declarative and clean.

export const requireRole = (allowedRoles: string[]) => {
  return (req: Request, res: Response, next: NextFunction) => {
    // JWT is verified by prior authMiddleware, user is attached to req
    const userRole = (req as any).user?.role;
    
    if (!userRole || !allowedRoles.includes(userRole)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
};

// Usage in a route
router.get('/admin-dashboard',
  authMiddleware, // Verifies JWT from cookie
  requireRole(['admin']),
  (req, res) => { /* ... */ }
);

What Broke and How I Fixed It

The first major issue was Google’s ID token verification failing intermittently in production. The error was cryptic, often just "Invalid Token". I discovered the problem was a clock skew between my server and Google’s servers. The fix was to use the official google-auth-library and allow a small leeway in token expiration time.

import { OAuth2Client } from 'google-auth-library';
const client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);

async function verifyGoogleToken(idToken: string) {
  const ticket = await client.verifyIdToken({
    idToken,
    audience: process.env.GOOGLE_CLIENT_ID,
  });
  // The library handles clock skew internally
  const payload = ticket.getPayload();
  return payload;
}

The second problem was session fixation during the OAuth flow. Initially, I was generating a session JWT before redirecting to Google, which created a security risk. I refactored the flow to be completely stateless until the OAuth callback. All necessary state (like a return URL) is now passed via a signed, short-lived parameter or stored in a temporary, single-use database record.

How to Build Something Similar

Start by setting up the Google OAuth 2.0 credentials in the Google Cloud Console. Your callback URL will be something like https://yourdomain.com/auth/google/callback. Then, structure your Express app with these core routes:

// authRoutes.ts
router.get('/auth/google', (req, res) => {
  // Generate the Google OAuth URL and redirect
  const url = `https://accounts.google.com/o/oauth2/v2/auth?${queryParams}`;
  res.redirect(url);
});

router.get('/auth/google/callback', async (req, res) => {
  const { code } = req.query;
  // 1. Exchange code for tokens with Google
  // 2. Verify the ID token using google-auth-library
  // 3. Find/upsert user in your DB
  // 4. Generate your own JWT
  // 5. Set it in an HttpOnly cookie
  // 6. Redirect to your frontend
});

router.get('/auth/logout', (req, res) => {
  res.clearCookie('auth_token');
  res.json({ message: 'Logged out' });
});

Your MongoDB user schema should at minimum store the Google ID (sub claim), email, name, and a role field (defaulting to 'user').

Would I Build It the Same Way Again?

For a monolithic Express app, yes. The cookie-based JWT pattern is robust for server-side rendered apps or SPAs on the same domain. However, for a microservices architecture or a native mobile app backend, I would consider using refresh and access token rotation. In that model, the short-lived JWT remains, but a separate refresh token (stored securely in the DB) is used to get new JWTs without forcing a full OAuth re-login.

The one thing you should know before starting is that OAuth is a delegation protocol, not an authentication protocol. You must verify the ID token on your backend—never trust identity data sent directly from the client.

Related posts

Written by Suhail Roushan — Full-stack developer. More posts on AI, Next.js, and building products at suhailroushan.com/blog.

Get in touch