June 16, 2026
Build an IDOR Vulnerability Lab: Why WHERE Clauses Don’t Protect Your API.
Last time we covered SQL injection. I promised IDOR was next. Today you are going to see why a WHERE clause alone will not save you.
ShadowForge
5 min read
When you learn about backend APIs feeding your frontend, you are really glad you get to make a call, some magic that you created happens, and it appears on the screen. The big thing I never learned about in school was the concept of those API calls being modified before they ever reach the server. Because of this, I never really learned how to make queries safe. And no, I am not talking about parameterised queries like in the last blog. Sure I learned to query a database where the records belonged to bob with a WHERE clause, but no one ever told me that this concept of ownership should also be enforced on API calls.
Ownership
I lightly brushed on the term ownership. Now I want to go a bit further in detail about it.
Like I already said, in school I learned the basics of the WHERE clause. Put simply, you only retrieve the rows that match a certain column value. Let's say we are CEO of a company and we want to know which assets our employees have. That would be a query like SELECT * FROM assets.
[
{"id":1, "lender":"liam", "asset":"Laptop"},
{"id":2, "lender":"bob", "asset":"Desktop"},
{"id":3, "lender":"sarah", "asset":"Mobile Phone"},
{"id":4, "lender":"bob", "asset":"Tablet"},
][
{"id":1, "lender":"liam", "asset":"Laptop"},
{"id":2, "lender":"bob", "asset":"Desktop"},
{"id":3, "lender":"sarah", "asset":"Mobile Phone"},
{"id":4, "lender":"bob", "asset":"Tablet"},
]If the CEO would like to check which assets bob has, we need to add a WHERE clause like SELECT * FROM assets WHERE lender = 'bob'. Instead of four rows, we now get two rows back.
[
{"id":2, "lender":"bob", "asset":"Desktop"},
{"id":4, "lender":"bob", "asset":"Tablet"},
][
{"id":2, "lender":"bob", "asset":"Desktop"},
{"id":4, "lender":"bob", "asset":"Tablet"},
]This is the principle of ownership. The query only returns what belongs to bob. Simple enough. But ownership is not just a database concern — it also needs to be enforced at the API layer. That is what this lab is about.
Lab Tree
The lab we will be building today is structured like this.
/mediumLabs
└── /IDOR
├── /node_modules
├── login.html
├── mySecrets.html
├── server.js
├── package-lock.json
└── package.json/mediumLabs
└── /IDOR
├── /node_modules
├── login.html
├── mySecrets.html
├── server.js
├── package-lock.json
└── package.jsonWe will log in and be redirected to a page that will contain all of our personal secrets.
Boilerplate
Like always, I will provide you with some boilerplate code to get us started.
login.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<form method="POST" action="/login">
<div>
<label>Username: <input type="text" name="username" required></label>
</div>
<div>
<label>Password: <input type="password" name="password" required></label>
</div>
<button type="submit">Login</button>
</form>
</body>
</html><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<form method="POST" action="/login">
<div>
<label>Username: <input type="text" name="username" required></label>
</div>
<div>
<label>Password: <input type="password" name="password" required></label>
</div>
<button type="submit">Login</button>
</form>
</body>
</html>mySecrets.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Secrets</title>
</head>
<body>
<h1>My Secrets</h1>
<button id="loadBtn">Load my secrets</button>
<div id="secrets"></div>
<br>
<a href="/logout">Logout</a>
<script>
function getCookie(name) {
return document.cookie.split('; ').find(r => r.startsWith(name + '='))?.split('=')[1];
}
function decodeJWT(token) {
return JSON.parse(atob(token.split('.')[1]));
}
document.getElementById('loadBtn').addEventListener('click', async () => {
const token = getCookie('token');
const payload = decodeJWT(token);
const userId = payload.id;
const res = await fetch(`/api/secrets/${userId}`);
const secrets = await res.json();
const container = document.getElementById('secrets');
container.innerHTML = secrets.map(s =>
`<div><strong>${s.title}</strong>: ${s.content}</div>`
).join('');
});
</script>
</body>
</html><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Secrets</title>
</head>
<body>
<h1>My Secrets</h1>
<button id="loadBtn">Load my secrets</button>
<div id="secrets"></div>
<br>
<a href="/logout">Logout</a>
<script>
function getCookie(name) {
return document.cookie.split('; ').find(r => r.startsWith(name + '='))?.split('=')[1];
}
function decodeJWT(token) {
return JSON.parse(atob(token.split('.')[1]));
}
document.getElementById('loadBtn').addEventListener('click', async () => {
const token = getCookie('token');
const payload = decodeJWT(token);
const userId = payload.id;
const res = await fetch(`/api/secrets/${userId}`);
const secrets = await res.json();
const container = document.getElementById('secrets');
container.innerHTML = secrets.map(s =>
`<div><strong>${s.title}</strong>: ${s.content}</div>`
).join('');
});
</script>
</body>
</html>server.js
const express = require('express');
const Database = require('better-sqlite3');
const jwt = require('jsonwebtoken');
const path = require('path');
const app = express();
const db = new Database(':memory:');
const JWT_SECRET = 'idor-lab-secret';
db.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT,
password TEXT
)
`);
db.exec(`
CREATE TABLE secrets (
id INTEGER PRIMARY KEY,
user_id INTEGER,
title TEXT,
content TEXT
)
`);
const insertUser = db.prepare('INSERT INTO users (username, password) VALUES (?, ?)');
[
['alice', 'alice123'],
['bob', 'bob123'],
['carol', 'carol123'],
['dave', 'dave123'],
].forEach(([u, p]) => insertUser.run(u, p));
const insertSecret = db.prepare('INSERT INTO secrets (user_id, title, content) VALUES (?, ?, ?)');
[
[1, 'Bank PIN', '4821'],
[1, 'Email password', 'alice@secret99'],
[1, 'SSH passphrase', 'fluffy_bunny_2024'],
[2, 'Credit card CVV', '737 (card ending 4242)'],
[2, 'WiFi password', 'supersecret42'],
[2, 'Work credentials', 'bob@corp.com / W0rkPass!'],
[2, 'Personal diary', 'I have a crush on alice...'],
[3, 'Crypto seed', 'apple mango tiger cloud'],
[3, 'API key', 'sk-prod-xK92mL0pQ7rN3wZv'],
[3, 'Server password', 'root@prod: C4r0l$3cur3!'],
[3, 'Secret project', 'Launching project X next month'],
[4, 'Master password', 'D4ve_M4sterP@ss!'],
[4, 'SSN', '123-45-6789'],
[4, 'Private key', '-----BEGIN RSA PRIVATE KEY----- (truncated)'],
[4, 'Hidden account', 'shadow-bank.io: dave / hidden99'],
].forEach(([uid, title, content]) => insertSecret.run(uid, title, content));
app.use(express.urlencoded({ extended: false }));
function getToken(req) {
const match = (req.headers.cookie || '').match(/token=([^;]+)/);
return match ? match[1] : null;
}
function requireAuth(req, res, next) {
try {
req.user = jwt.verify(getToken(req), JWT_SECRET);
next();
} catch {
res.redirect('/');
}
}
app.get('/', (req, res) => res.sendFile(path.join(__dirname, 'login.html')));
app.post('/login', (req, res) => {
const { username, password } = req.body;
const user = db.prepare('SELECT * FROM users WHERE username = ? AND password = ?').get(username, password);
if (!user) return res.redirect('/?error=Invalid+credentials');
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '1h' });
res.setHeader('Set-Cookie', `token=${token}; Path=/`);
res.redirect('/secrets');
});
app.get('/secrets', requireAuth, (req, res) => res.sendFile(path.join(__dirname, 'mySecrets.html')));
app.get('/api/secrets/:id', requireAuth, (req, res) => {
const secrets = db.prepare('SELECT * FROM secrets WHERE user_id = ?').all(req.params.id);
res.json(secrets);
});
app.get('/logout', (req, res) => {
res.setHeader('Set-Cookie', 'token=; Max-Age=0; Path=/');
res.redirect('/');
});
app.listen(3000, () => console.log('Listening on http://localhost:3000'));const express = require('express');
const Database = require('better-sqlite3');
const jwt = require('jsonwebtoken');
const path = require('path');
const app = express();
const db = new Database(':memory:');
const JWT_SECRET = 'idor-lab-secret';
db.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT,
password TEXT
)
`);
db.exec(`
CREATE TABLE secrets (
id INTEGER PRIMARY KEY,
user_id INTEGER,
title TEXT,
content TEXT
)
`);
const insertUser = db.prepare('INSERT INTO users (username, password) VALUES (?, ?)');
[
['alice', 'alice123'],
['bob', 'bob123'],
['carol', 'carol123'],
['dave', 'dave123'],
].forEach(([u, p]) => insertUser.run(u, p));
const insertSecret = db.prepare('INSERT INTO secrets (user_id, title, content) VALUES (?, ?, ?)');
[
[1, 'Bank PIN', '4821'],
[1, 'Email password', 'alice@secret99'],
[1, 'SSH passphrase', 'fluffy_bunny_2024'],
[2, 'Credit card CVV', '737 (card ending 4242)'],
[2, 'WiFi password', 'supersecret42'],
[2, 'Work credentials', 'bob@corp.com / W0rkPass!'],
[2, 'Personal diary', 'I have a crush on alice...'],
[3, 'Crypto seed', 'apple mango tiger cloud'],
[3, 'API key', 'sk-prod-xK92mL0pQ7rN3wZv'],
[3, 'Server password', 'root@prod: C4r0l$3cur3!'],
[3, 'Secret project', 'Launching project X next month'],
[4, 'Master password', 'D4ve_M4sterP@ss!'],
[4, 'SSN', '123-45-6789'],
[4, 'Private key', '-----BEGIN RSA PRIVATE KEY----- (truncated)'],
[4, 'Hidden account', 'shadow-bank.io: dave / hidden99'],
].forEach(([uid, title, content]) => insertSecret.run(uid, title, content));
app.use(express.urlencoded({ extended: false }));
function getToken(req) {
const match = (req.headers.cookie || '').match(/token=([^;]+)/);
return match ? match[1] : null;
}
function requireAuth(req, res, next) {
try {
req.user = jwt.verify(getToken(req), JWT_SECRET);
next();
} catch {
res.redirect('/');
}
}
app.get('/', (req, res) => res.sendFile(path.join(__dirname, 'login.html')));
app.post('/login', (req, res) => {
const { username, password } = req.body;
const user = db.prepare('SELECT * FROM users WHERE username = ? AND password = ?').get(username, password);
if (!user) return res.redirect('/?error=Invalid+credentials');
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '1h' });
res.setHeader('Set-Cookie', `token=${token}; Path=/`);
res.redirect('/secrets');
});
app.get('/secrets', requireAuth, (req, res) => res.sendFile(path.join(__dirname, 'mySecrets.html')));
app.get('/api/secrets/:id', requireAuth, (req, res) => {
const secrets = db.prepare('SELECT * FROM secrets WHERE user_id = ?').all(req.params.id);
res.json(secrets);
});
app.get('/logout', (req, res) => {
res.setHeader('Set-Cookie', 'token=; Max-Age=0; Path=/');
res.redirect('/');
});
app.listen(3000, () => console.log('Listening on http://localhost:3000'));package.json
{
"name": "idor",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "",
"type": "commonjs",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"better-sqlite3": "^12.10.0",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"nodemon": "^3.1.14"
}
}{
"name": "idor",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "",
"type": "commonjs",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"better-sqlite3": "^12.10.0",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"nodemon": "^3.1.14"
}
}Wait. What Is Vulnerable Here?
If you read through server.js, it looks pretty solid at first glance. We have a requireAuth middleware. We have a WHERE clause in the query. Passwords are checked. Tokens are verified. So what is the problem?
Look at this endpoint specifically:
app.get('/api/secrets/:id', requireAuth, (req, res) => {
const secrets = db.prepare('SELECT * FROM secrets WHERE user_id = ?').all(req.params.id);
res.json(secrets);
});app.get('/api/secrets/:id', requireAuth, (req, res) => {
const secrets = db.prepare('SELECT * FROM secrets WHERE user_id = ?').all(req.params.id);
res.json(secrets);
});The WHERE user_id = ? clause does enforce ownership at the database level. But notice what value we are passing in: req.params.id. That value comes from the URL. And the URL comes from the user.
In Burp Suite, our request looks like this.
Seems fine, right? We are logged in as alice, we are requesting ID 1, and alice is user 1. All good.
Now watch what happens when we just change that ID.
We are still authenticated as Alice. But we are now reading Bob's secrets. The server never checked whether Alice is actually allowed to request data for user 2. It just trusted the number in the URL.
The Problem
The query checks that the data belongs to a user. It does not check that the user making the request is that user.
Those are two completely different things, and conflating them is exactly what makes IDOR such a common and damaging vulnerability. The database is doing its job. The problem is that we never told the application to verify that the ID in the URL matches the identity of the person asking.
Enforcing Ownership Server Side
The fix is one line of code:
app.get('/api/secrets/:id', requireAuth, (req, res) => {
if (req.user.id !== parseInt(req.params.id)) return res.status(403).json({ error: 'Forbidden' });
const secrets = db.prepare('SELECT * FROM secrets WHERE user_id = ?').all(req.user.id);
res.json(secrets);
});app.get('/api/secrets/:id', requireAuth, (req, res) => {
if (req.user.id !== parseInt(req.params.id)) return res.status(403).json({ error: 'Forbidden' });
const secrets = db.prepare('SELECT * FROM secrets WHERE user_id = ?').all(req.user.id);
res.json(secrets);
});We compare req.user.id (which comes from the verified JWT via requireAuth) against req.params.id (which comes from the URL). If they do not match, the request is rejected with a 403 Forbidden before we ever touch the database.
Notice that we also changed the query to use req.user.id instead of req.params.id. Since we have already verified they match, this makes the URL parameter irrelevant. The only ID that matters is the one baked into the token.
req.useris the decoded JWT payload set byrequireAuth. Because the token is cryptographically signed, it cannot be tampered with by the client. That is why we can trust it. That is also why a weak or misconfigured JWT implementation would immediately undermine this entire defence.
JWT
Which brings us to next time. A solid ownership check is only as strong as the token backing it. Next time we will look at two ways JWT implementation can go wrong and how attackers exploit both. Until then.
I break web apps for fun, make vulnerable labs to learn, and write about it so you can too.