Introduction
If you've shipped more than one Node.js API, you've probably rebuilt authentication from scratch at least twice — and hated it both times. It's one of those systems that looks simple on a whiteboard ("just check a token, right?") and then quietly turns into the most security-critical, most bug-prone part of your entire backend.
Here's the thing: most tutorials teach you either JWT or OAuth2, as if you're only ever going to need one. In real products, you almost always need both. Your mobile app and internal services want fast, stateless JWT verification. Your web app wants "Sign in with Google" because nobody wants to remember another password. And your product team wants admins, editors, and regular users to see different things — which means you also need role-based access control (RBAC) sitting on top of all of it.
In this guide, we're going to build a complete authentication system in Node.js and Express that does all three:
- JWT-based authentication for stateless, scalable session verification
- Google OAuth2 for social login and delegated authorization
- MongoDB-backed session/refresh token handling so you can actually revoke access when you need to
- Role-based access control to gate routes by user permissions
By the end, you'll have a mental model — and working code — for an auth system you'd actually be comfortable deploying in 2026, not a toy example that falls apart the moment someone tries to log out.
JWT vs OAuth2: Clearing Up the Confusion
Before writing a single line of code, it's worth untangling a common misconception: JWT and OAuth2 are not competitors. They solve different problems and often work together.
What JWT Actually Is
A JSON Web Token (JWT) is just a compact, signed data format. It's a way of encoding claims (like userId, role, or email) into a string that a server can verify without querying a database, because the signature proves the payload hasn't been tampered with.
A JWT has three parts, separated by dots:
header.payload.signature
- Header — algorithm and token type
- Payload — the actual claims (data)
- Signature — a cryptographic proof, signed with a secret or private key
JWT answers the question: "Can I trust the claims in this token without hitting the database?"
What OAuth2 Actually Is
OAuth2 is a completely different thing — it's an authorization framework, not a token format. It defines a protocol for letting a user grant a third-party application limited access to their data on another service (like Google, GitHub, or Facebook) without ever sharing their password with your app.
OAuth2 answers a different question: "Can this user prove who they are using an identity they already trust, and can my app get permission to act on their behalf?"
Why You Usually Need Both
In a typical modern app:
- A user logs in via Google OAuth2 (no password to manage, better conversion rates, delegated trust).
- Once authenticated, your backend issues its own JWT representing that user's session within your system.
- Every subsequent API request is authenticated using that JWT — fast, stateless, and independent of Google's servers.
This is exactly the architecture we're building below.
Architecture Overview
Here's the flow we're implementing:
1. User clicks "Sign in with Google"
2. Google authenticates the user and redirects back with an authorization code
3. Express exchanges the code for the user's Google profile
4. Backend finds or creates the user in MongoDB
5. Backend issues:
- a short-lived JWT access token
- a long-lived refresh token (stored in MongoDB + HTTP-only cookie)
6. Client uses the access token on every API request
7. When the access token expires, the client silently exchanges
the refresh token for a new one
8. Role-based middleware checks the JWT's role claim before
allowing access to protected routes
Traditional email/password JWT login runs in parallel, using the same token-issuing logic — so users can sign in either way.
Setting Up the Project
Let's scaffold the project first.
mkdir secure-auth-api && cd secure-auth-api
npm init -y
npm install express mongoose dotenv cookie-parser cors \
jsonwebtoken bcryptjs passport passport-google-oauth20 \
express-rate-limit helmet
Here's the folder structure we'll use:
secure-auth-api/
├── src/
│ ├── config/
│ │ ├── db.js
│ │ └── passport.js
│ ├── models/
│ │ ├── User.js
│ │ └── RefreshToken.js
│ ├── middleware/
│ │ ├── authenticate.js
│ │ └── authorize.js
│ ├── controllers/
│ │ └── authController.js
│ ├── routes/
│ │ └── authRoutes.js
│ ├── utils/
│ │ └── tokens.js
│ └── app.js
├── .env
└── package.json
Environment Variables
# .env
PORT=5000
MONGO_URI=mongodb://localhost:27017/secure-auth-db
JWT_ACCESS_SECRET=replace_with_a_long_random_string
JWT_REFRESH_SECRET=replace_with_a_different_long_random_string
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
GOOGLE_CALLBACK_URL=http://localhost:5000/api/auth/google/callback
CLIENT_URL=http://localhost:3000
Never commit your
.envfile. Add it to.gitignoreon day one, not after you've already leaked a secret to GitHub.
Building the User Model
Our User schema needs to support both password-based and OAuth-based accounts, plus a role field for access control.
// src/models/User.js
import mongoose from "mongoose";
import bcrypt from "bcryptjs";
const userSchema = new mongoose.Schema(
{
name: { type: String, required: true },
email: { type: String, required: true, unique: true, lowercase: true },
password: { type: String, select: false }, // not required for OAuth users
googleId: { type: String, unique: true, sparse: true },
avatar: { type: String },
role: {
type: String,
enum: ["user", "editor", "admin"],
default: "user",
},
isVerified: { type: Boolean, default: false },
},
{ timestamps: true }
);
userSchema.pre("save", async function (next) {
if (!this.isModified("password") || !this.password) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});
userSchema.methods.comparePassword = function (candidate) {
return bcrypt.compare(candidate, this.password);
};
export default mongoose.model("User", userSchema);
Notice password: { select: false }. This is a small but important habit — it keeps password hashes out of query results by default, so you can't accidentally leak them in an API response.
Refresh Token Model
Instead of trusting refresh tokens blindly, we store a record of each one in MongoDB. This is what lets us revoke sessions — something a stateless JWT alone can never do.
// src/models/RefreshToken.js
import mongoose from "mongoose";
const refreshTokenSchema = new mongoose.Schema({
user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
token: { type: String, required: true, unique: true },
expiresAt: { type: Date, required: true },
createdByIp: { type: String },
revoked: { type: Boolean, default: false },
});
// MongoDB TTL index — auto-deletes expired tokens
refreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
export default mongoose.model("RefreshToken", refreshTokenSchema);
That TTL index is a nice touch — MongoDB will automatically clean up expired tokens for you, so your collection doesn't grow forever.
Implementing JWT Authentication
Token Utility Functions
Keep token generation logic in one place so you're not duplicating signing options across the codebase.
// src/utils/tokens.js
import jwt from "jsonwebtoken";
import crypto from "crypto";
export const generateAccessToken = (user) => {
return jwt.sign(
{ sub: user._id, role: user.role },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: process.env.JWT_ACCESS_EXPIRY }
);
};
export const generateRefreshToken = () => {
return crypto.randomBytes(40).toString("hex");
};
Note that the refresh token isn't a JWT at all — it's just a random, opaque string. This is intentional. Since we validate it against the database anyway, there's no benefit to making it a self-describing JWT, and an opaque token leaks zero information if intercepted.
Register and Login Controller
// src/controllers/authController.js
import User from "../models/User.js";
import RefreshToken from "../models/RefreshToken.js";
import { generateAccessToken, generateRefreshToken } from "../utils/tokens.js";
const REFRESH_COOKIE_OPTIONS = {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
};
export const register = async (req, res) => {
const { name, email, password } = req.body;
const existing = await User.findOne({ email });
if (existing) {
return res.status(409).json({ message: "Email already registered" });
}
const user = await User.create({ name, email, password });
return issueTokensAndRespond(user, req, res, 201);
};
export const login = async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email }).select("+password");
if (!user || !user.password) {
return res.status(401).json({ message: "Invalid credentials" });
}
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return res.status(401).json({ message: "Invalid credentials" });
}
return issueTokensAndRespond(user, req, res, 200);
};
async function issueTokensAndRespond(user, req, res, statusCode) {
const accessToken = generateAccessToken(user);
const refreshTokenValue = generateRefreshToken();
await RefreshToken.create({
user: user._id,
token: refreshTokenValue,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
createdByIp: req.ip,
});
res.cookie("refreshToken", refreshTokenValue, REFRESH_COOKIE_OPTIONS);
return res.status(statusCode).json({
accessToken,
user: { id: user._id, name: user.name, email: user.email, role: user.role },
});
}
A few deliberate choices here worth calling out:
- The access token goes in the JSON response body — the frontend keeps it in memory (not
localStorage) and attaches it via anAuthorizationheader. - The refresh token goes in an HTTP-only, secure cookie — JavaScript can't read it, which blocks XSS-based token theft.
- Every refresh token is logged in MongoDB with the requesting IP, so you have an audit trail if something looks off.
Refresh and Logout Endpoints
// src/controllers/authController.js (continued)
export const refresh = async (req, res) => {
const token = req.cookies?.refreshToken;
if (!token) return res.status(401).json({ message: "No refresh token" });
const stored = await RefreshToken.findOne({ token, revoked: false });
if (!stored || stored.expiresAt < new Date()) {
return res.status(403).json({ message: "Invalid or expired session" });
}
const user = await User.findById(stored.user);
const accessToken = generateAccessToken(user);
return res.json({ accessToken });
};
export const logout = async (req, res) => {
const token = req.cookies?.refreshToken;
if (token) {
await RefreshToken.findOneAndUpdate({ token }, { revoked: true });
}
res.clearCookie("refreshToken", REFRESH_COOKIE_OPTIONS);
return res.status(204).send();
};
Logout doesn't just clear a cookie — it revokes the token server-side. This is the detail most tutorials skip, and it's the difference between "logout" being cosmetic versus actually secure.
Adding Google OAuth2
Now let's layer in social login using passport-google-oauth20.
Passport Configuration
// src/config/passport.js
import passport from "passport";
import { Strategy as GoogleStrategy } from "passport-google-oauth20";
import User from "../models/User.js";
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK_URL,
},
async (accessToken, refreshToken, profile, done) => {
try {
let user = await User.findOne({ googleId: profile.id });
if (!user) {
// Link accounts if the email already exists
user = await User.findOne({ email: profile.emails[0].value });
if (user) {
user.googleId = profile.id;
await user.save();
} else {
user = await User.create({
name: profile.displayName,
email: profile.emails[0].value,
googleId: profile.id,
avatar: profile.photos?.[0]?.value,
isVerified: true,
});
}
}
return done(null, user);
} catch (err) {
return done(err, null);
}
}
)
);
export default passport;
Linking accounts by email (when a user first registered with a password, then later tries Google login) is a small detail that saves your support team a lot of "why do I have two accounts" tickets.
OAuth Routes
// src/routes/authRoutes.js
import express from "express";
import passport from "../config/passport.js";
import { register, login, refresh, logout } from "../controllers/authController.js";
import { issueTokensForOAuthUser } from "../controllers/authController.js";
const router = express.Router();
router.post("/register", register);
router.post("/login", login);
router.post("/refresh", refresh);
router.post("/logout", logout);
router.get(
"/google",
passport.authenticate("google", { scope: ["profile", "email"], session: false })
);
router.get(
"/google/callback",
passport.authenticate("google", { session: false, failureRedirect: "/login" }),
issueTokensForOAuthUser
);
export default router;
Notice session: false — since we're issuing our own JWTs, we don't need Passport's default session middleware or express-session. That keeps the app fully stateless on the request-handling side, while MongoDB still tracks refresh tokens for revocation.
The callback controller reuses the same token-issuing logic as regular login, then redirects to the frontend with the access token (or, better, sets the cookie and redirects to a page that fetches the token via /refresh):
// src/controllers/authController.js (continued)
export const issueTokensForOAuthUser = async (req, res) => {
const user = req.user;
const accessToken = generateAccessToken(user);
const refreshTokenValue = generateRefreshToken();
await RefreshToken.create({
user: user._id,
token: refreshTokenValue,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
createdByIp: req.ip,
});
res.cookie("refreshToken", refreshTokenValue, REFRESH_COOKIE_OPTIONS);
res.redirect(`${process.env.CLIENT_URL}/oauth-success?token=${accessToken}`);
};
Role-Based Access Control (RBAC)
With login sorted, we need middleware to (1) verify the JWT and (2) enforce role permissions.
Authentication Middleware
// src/middleware/authenticate.js
import jwt from "jsonwebtoken";
export const authenticate = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ message: "No token provided" });
}
const token = authHeader.split(" ")[1];
try {
const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
req.user = { id: decoded.sub, role: decoded.role };
next();
} catch (err) {
if (err.name === "TokenExpiredError") {
return res.status(401).json({ message: "Access token expired" });
}
return res.status(403).json({ message: "Invalid token" });
}
};
Returning a distinct TokenExpiredError message matters — it lets the frontend know to silently call /refresh instead of forcing a full logout.
Authorization Middleware
// src/middleware/authorize.js
export const authorize = (...allowedRoles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ message: "Not authenticated" });
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({ message: "Insufficient permissions" });
}
next();
};
};
Using It in Routes
// src/routes/adminRoutes.js
import express from "express";
import { authenticate } from "../middleware/authenticate.js";
import { authorize } from "../middleware/authorize.js";
const router = express.Router();
router.get(
"/dashboard",
authenticate,
authorize("admin"),
(req, res) => res.json({ message: "Welcome, admin" })
);
router.put(
"/articles/:id",
authenticate,
authorize("admin", "editor"),
(req, res) => res.json({ message: "Article updated" })
);
export default router;
This pattern — authenticate then authorize(...) — reads clearly at a glance, keeps permission logic out of your business logic, and is trivial to unit test in isolation.
Real-World Example: Protecting a Blog API
Let's tie it together with a realistic scenario: a blog platform where:
- Anyone can read published posts
- Only
editorandadminroles can create/edit posts - Only
admincan delete posts or manage users
// src/routes/postRoutes.js
import express from "express";
import { authenticate } from "../middleware/authenticate.js";
import { authorize } from "../middleware/authorize.js";
import { getPosts, createPost, deletePost } from "../controllers/postController.js";
const router = express.Router();
router.get("/", getPosts); // public
router.post("/", authenticate, authorize("editor", "admin"), createPost);
router.delete("/:id", authenticate, authorize("admin"), deletePost);
export default router;
This is the kind of clean, declarative access control that scales well as your app grows to dozens of routes — you can scan the routes file and immediately understand who can do what, without digging through controller logic.
Best Practices for 2026
- Use short-lived access tokens (10–15 minutes). They minimize the damage window if one leaks.
- Store refresh tokens server-side, even though they live in a cookie on the client. This gives you real revocation, not just expiry.
- Always use HTTP-only, secure,
SameSite=strictcookies for refresh tokens — neverlocalStorage, which is fully exposed to XSS. - Rotate refresh tokens on every use (issue a new one, revoke the old) to limit replay attacks — a pattern often called refresh token rotation.
- Rate-limit auth endpoints with
express-rate-limitto blunt brute-force and credential-stuffing attempts. - Use
helmetto set sensible security headers by default. - Validate and sanitize all inputs — never trust
req.bodyblindly, even on internal-facing routes. - Hash passwords with bcrypt (cost factor ≥ 12) or consider argon2 for new projects.
- Keep JWT payloads minimal. Don't cram user profile data into the token — a
subandroleclaim is usually enough. - Set distinct secrets for access and refresh token signing, and rotate them periodically.
Common Mistakes to Avoid
- Storing JWTs in
localStorage. It feels convenient, but it's directly readable by any injected script — a single XSS vulnerability compromises every logged-in user. - Making access tokens long-lived "for convenience." A 7-day access token with no revocation mechanism is a liability, not a feature.
- Skipping refresh token storage. If you only validate refresh tokens by signature (like a JWT) and never check a database, you can never truly log a user out.
- Not linking OAuth and password accounts by email, resulting in duplicate accounts and confused users.
- Putting authorization logic inside controllers instead of middleware, leading to inconsistent, hard-to-audit permission checks scattered across the codebase.
- Forgetting to set
session: falsein Passport when using JWTs, which quietly bolts on an unnecessary session layer. - Returning identical error messages for "user not found" and "wrong password" during login — wait, actually you should do this. The common mistake is the opposite: leaking which one failed, which helps attackers enumerate valid emails.
- Ignoring token expiry handling on the frontend, causing users to get logged out abruptly instead of silently refreshing.
🚀 Pro Tips
- Use a library like
zodorjoito validatereq.bodyon every auth route — malformed input is one of the most common sources of 500 errors in auth systems. - Add a
tokenVersionfield to yourUsermodel and include it in the JWT payload. Incrementing it on password change instantly invalidates every existing access token for that user, even before they expire. - In production, put your auth routes behind a slightly stricter rate limiter than the rest of your API — login and refresh endpoints are prime brute-force targets.
- If you support multiple OAuth providers later (GitHub, Microsoft), design your
Userschema with a genericproviders: [{ name, providerId }]array from day one instead of one field per provider. - Log authentication events (login, logout, failed attempts, token refresh) to a separate audit collection — it's invaluable during incident response.
- Test your revocation logic explicitly: log in, log out, then try to use the old refresh token. If it still works, you have a bug, not a feature.
📌 Key Takeaways
- JWT and OAuth2 are complementary, not competing — OAuth2 handles delegated identity, JWT handles stateless session verification.
- Persisting refresh tokens in MongoDB is what makes real session revocation possible; a purely client-side JWT scheme can't do this.
- HTTP-only cookies for refresh tokens plus in-memory access tokens is the safest client-side storage pattern available today.
- Role-based access control belongs in composable middleware (
authenticate→authorize), not scattered conditionals inside route handlers. - Small details — account linking by email, token versioning, audit logging — separate a production-grade auth system from a tutorial-grade one.
Conclusion
Authentication is one of those systems where "it works" and "it's secure" are two very different bars to clear. It's easy to wire up jsonwebtoken and call it done — but real production systems need revocation, role management, social login, and a client-side storage strategy that doesn't hand your users' sessions to the first XSS bug that slips through code review.
The pattern we built here — JWT for stateless verification, OAuth2 for delegated login, MongoDB for refresh token control, and middleware-driven RBAC — isn't the only valid architecture, but it's a solid, battle-tested baseline you can extend with multi-factor authentication, additional OAuth providers, or fine-grained permissions as your product grows.
Start with the fundamentals covered here, get them right, and the rest of your security posture gets a lot easier to build on top of.