The Incident: An Unprotected Blog API
In a Node.js Express application, we discovered a critical broken authentication vulnerability in api/routes/posts.js that left the entire posts API wide open. The POST endpoint at line 6 had no authentication whatsoever, while the PUT and DELETE endpoints used a username comparison that attackers could trivially bypass. This meant anyone on the internet could create posts, modify existing posts, or delete content—all without logging in.
The vulnerable code in api/routes/posts.js looked like this:
// CREATE POST
router.post("/", async (req, res) => {
const newPost = new Post(req.body);
try {
const savedPost = await newPost.save();
res.status(200).json(savedPost);
} catch (err) {
// res.status(500).json(err);
}
});
Notice what's missing? There's no authentication middleware, no token verification, no check to see if the requester is even logged in. The endpoint blindly accepts req.body and saves it directly to the database.
The Vulnerability Explained
This vulnerability had three critical flaws working together:
Flaw #1: Zero Authentication on POST
The POST endpoint at router.post("/", async (req, res) => { had absolutely no authentication check. An attacker could send a request like this:
curl -X POST http://api.example.com/api/posts \
-H "Content-Type: application/json" \
-d '{
"title": "Malicious Post",
"content": "Spam content here",
"username": "admin"
}'
The server would happily create the post with whatever username the attacker specified. No password required. No token needed. Just pure, unauthenticated post creation.
Flaw #2: Client-Controlled Authorization Checks
The PUT and DELETE endpoints had authorization checks, but they were worse than useless—they created a false sense of security:
// UPDATE POST
router.put("/:id", async (req, res) => {
try {
const post = await Post.findById(req.params.id);
if (post.username === req.body.username) { // ⚠️ DANGEROUS
// ... update the post
}
}
});
This code compares post.username (from the database) against req.body.username (from the attacker's request). An attacker could simply look up who wrote a post and include that username in their request:
# Step 1: Find a post by user "alice"
curl http://api.example.com/api/posts/123
# Response: { "id": "123", "username": "alice", "title": "Alice's Post" }
# Step 2: Delete it by claiming to be alice
curl -X DELETE http://api.example.com/api/posts/123 \
-H "Content-Type: application/json" \
-d '{"username": "alice"}'
# Success! Post deleted without any password or authentication.
Flaw #3: No Server-Side Token Verification
The code never calls any verifyToken middleware or checks JWT tokens. There's no req.user populated by authentication middleware, no session validation, nothing. The application trusts whatever the client sends in the request body.
Real-World Impact
For this blog application, the impact was severe:
- Content vandalism: Attackers could create spam posts as any user
- Data manipulation: Existing posts could be edited to spread misinformation
- Content deletion: Popular posts could be deleted to disrupt the platform
- Reputation damage: Posts created with forged usernames would appear legitimate
- No audit trail: Without proper authentication, there's no way to trace who actually made changes
The Fix: Database-Backed User Validation
The fix implements proper authentication by validating the userId against the actual User database before allowing any post operations. Here's the corrected POST endpoint:
Before:
router.post("/", async (req, res) => {
const newPost = new Post(req.body);
try {
const savedPost = await newPost.save();
res.status(200).json(savedPost);
} catch (err) {
// res.status(500).json(err);
}
});
After:
router.post("/", async (req, res) => {
try {
const user = await User.findById(req.body.userId);
if (!user) {
return res.status(401).json("You are not authenticated!");
}
const newPost = new Post({ ...req.body, username: user.username });
const savedPost = await newPost.save();
res.status(200).json(savedPost);
} catch (err) {
res.status(500).json(err);
}
});
Key Security Improvements
- Database validation: The code now calls
User.findById(req.body.userId)to verify the user exists in the database - Early return on failure: If no user is found, the endpoint returns 401 immediately
- Server-side username assignment: Instead of trusting
req.body.username, the code usesuser.usernamefrom the verified database record - Proper error handling: The commented-out error handler is now active
The PUT and DELETE endpoints received similar fixes:
Before (PUT):
const post = await Post.findById(req.params.id);
if (post.username === req.body.username) { // ⚠️ Client-controlled
// ... update logic
}
After (PUT):
const user = await User.findById(req.body.userId);
if (!user) {
return res.status(401).json("You are not authenticated!");
}
const post = await Post.findById(req.params.id);
if (post.username === user.username) { // ✅ Server-verified
// ... update logic
}
Now the authorization check compares post.username against user.username from the database lookup, not from the client's request body. An attacker can't forge this because they'd need a valid userId that actually exists in the User table.
Why This Fix Works
The fix transforms the security model from "trust the client" to "verify on the server":
- Database as source of truth: Every request must reference a real user ID that exists in the database
- No client-controlled authorization: The username comes from the User model, not from
req.body - Fail-safe defaults: If the user lookup fails, the request is rejected before any database writes
However, this is still an incomplete fix for production. The code validates that a userId exists but doesn't verify that the requester actually is that user. A complete solution would:
- Add JWT middleware to verify tokens and populate
req.user - Compare
req.user.id(from the verified token) against the user ID - Remove
userIdfrom the request body entirely—it should come from the authenticated session
Prevention & Best Practices
1. Use Authentication Middleware
Express applications should use middleware like passport.js or custom JWT verification:
const verifyToken = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json("No token provided");
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // Populate req.user with verified data
next();
} catch (err) {
return res.status(403).json("Invalid token");
}
};
router.post("/", verifyToken, async (req, res) => {
// req.user is now verified and trustworthy
});
2. Never Trust Client-Provided Identity
Any data in req.body, req.query, or req.params can be forged. Identity information must come from:
- Verified JWT tokens decoded server-side
- Server-side sessions looked up by session ID
- Database queries using authenticated user IDs
3. Implement Role-Based Access Control (RBAC)
For resource ownership checks:
const post = await Post.findById(req.params.id);
if (post.userId.toString() !== req.user.id) {
return res.status(403).json("Not authorized to modify this post");
}
4. Use Security Linters
Tools like ESLint with security plugins can catch missing authentication:
npm install --save-dev eslint-plugin-security
Configure rules to flag routes without authentication middleware.
5. Follow OWASP API Security Guidelines
The OWASP API Security Top 10 lists broken authentication as API1:2023. Key recommendations:
- Implement proper authentication mechanisms
- Use standard authentication solutions (OAuth 2.0, OpenID Connect)
- Don't reinvent authentication—use proven libraries
- Implement rate limiting to prevent brute force attacks
Key Takeaways
- The POST /api/posts endpoint had zero authentication, allowing anyone to create posts without logging in
- Client-controlled username comparisons are not authorization—attackers simply include the target username in their request body to bypass checks
- Always validate userId against your User database before processing requests, but this alone isn't sufficient
- Complete authentication requires JWT middleware or session verification to prove the requester actually owns the userId they're claiming
- Never trust req.body for identity information—always use server-verified tokens or sessions to populate req.user
How Orbis AppSec Detected This
- Source: HTTP request body parameters (
req.body.username,req.bodyin POST endpoint) - Sink: Database write operations (
newPost.save(),Post.findByIdAndUpdate()) inapi/routes/posts.js:6, :29, :58 - Missing control: No authentication middleware (verifyToken) before route handlers; authorization checks compared client-provided username instead of server-verified user identity
- CWE: CWE-287 (Improper Authentication) and CWE-306 (Missing Authentication for Critical Function)
- Fix: Added User.findById() validation to verify userId exists in database before processing post operations
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
This broken authentication vulnerability in api/routes/posts.js demonstrates why authentication must be handled server-side with verified tokens or sessions—never by trusting client-provided usernames or IDs. The fix adds database validation to ensure the userId exists, but production applications should go further by implementing proper JWT middleware that verifies tokens and populates req.user with authenticated identity. By following the principle of "verify, don't trust," and using established authentication patterns, you can prevent attackers from impersonating users and accessing resources they shouldn't control.
References
- CWE-287: Improper Authentication
- CWE-306: Missing Authentication for Critical Function
- OWASP API Security Top 10 - API1:2023 Broken Object Level Authorization
- OWASP Authentication Cheat Sheet
- Express.js Security Best Practices
- Semgrep Rules for Express Authentication
- fix: the post /api/posts endpoint has no authenticat... in posts.js