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.