Check ownership on every request
The most devastating API vulnerabilities are often the simplest: forgetting to check whether the current user actually owns the resource they're trying to access. I've seen production systems expose customer data, allow unauthorized edits, and even enable financial fraud because a single authorization check was missing. This isn't about sophisticated attack vectors—it's about discipline in a practice that should be automatic.
Never trust IDs from the client alone
When a user sends a request like GET /api/invoices/42, they're telling your API which resource they want. The critical mistake is assuming that because they can see the ID, they should be allowed to access it. IDs are public knowledge. A user could increment the ID, try a different GUID, or guess UUIDs. Without an ownership check, you've created an authorization bypass.
I built an e-commerce platform early in my career where we retrieved orders by ID without checking if the requester owned them. A customer discovered they could view anyone's order history just by changing the order ID in the URL. We had to notify affected customers and patch it immediately. The embarrassment was worse than the technical fix.
The pattern is straightforward: extract the user or tenant context from the authentication layer, then use it as a filter. Never make decisions based solely on what the client sends.
// ❌ WRONG: Trust the ID without ownership check
app.get('/api/invoices/:invoiceId', (req, res) => {
const invoice = db.invoices.findById(req.params.invoiceId);
res.json(invoice);
});
// ✅ CORRECT: Verify ownership before returning data
app.get('/api/invoices/:invoiceId', authenticate, (req, res) => {
const invoice = db.invoices.findOne({
id: req.params.invoiceId,
userId: req.user.id // Filter by current user
});
if (!invoice) {
return res.status(404).json({ error: 'Not found' });
}
res.json(invoice);
});The ownership check must happen on the server, inside an authenticated middleware. Never do it on the frontend. Frontend checks are trivial to bypass with browser dev tools or a custom HTTP client.
Filter queries by the current user or tenant
Authorization at scale requires thinking in terms of tenants or user scopes. Every query to your database should include a WHERE clause that filters by the current user or tenant ID. This is the foundation of multi-tenant systems and single-tenant apps alike.
In a SaaS platform I worked on, we had teams with multiple members. A member of Team A should never see documents from Team B, even if they somehow guessed the document ID. We made it a rule: every query includes the tenant ID.
// In a NestJS service with a team-based SaaS
async getDocument(documentId: string, userId: string) {
// Always filter by both the resource ID AND the user's team
const document = await this.db.documents.findOne({
where: {
id: documentId,
team: {
members: {
some: {
userId: userId
}
}
}
}
});
return document;
}
// In the controller
@Get('documents/:id')
@UseGuards(AuthGuard)
async getDocument(
@Param('id') documentId: string,
@Request() req
) {
const document = await this.service.getDocument(
documentId,
req.user.id
);
if (!document) {
throw new NotFoundException();
}
return document;
}For bulk operations like list endpoints, the same principle applies. Don't fetch all records and filter in code—filter in the database query itself.
// ❌ WRONG: Fetch all, filter in code
async listInvoices(userId: string) {
const allInvoices = await db.invoices.findAll();
return allInvoices.filter(inv => inv.userId === userId);
}
// ✅ CORRECT: Filter at the database level
async listInvoices(userId: string) {
return await db.invoices.findAll({
where: { userId: userId }
});
}This approach scales better and prevents accidental leaks if pagination or query limits are misconfigured.
Return 404 for records users can't access
When a user tries to access a resource they don't own, return a 404 Not Found, not a 403 Forbidden. This is a subtle but important distinction in API security.
A 403 tells an attacker: "You found a real resource, but you don't have permission." A 404 says: "This doesn't exist for you." This makes it harder for attackers to enumerate valid resource IDs. They can't distinguish between an ID that exists but is restricted versus an ID that doesn't exist.
// ✅ CORRECT: Return 404 to avoid leaking existence
@Get('invoices/:id')
@UseGuards(AuthGuard)
async getInvoice(@Param('id') id: string, @Request() req) {
const invoice = await this.invoiceService.findByIdAndUser(
id,
req.user.id
);
if (!invoice) {
// This could mean: the ID doesn't exist, or the user
// doesn't own it. The client can't tell which.
throw new NotFoundException();
}
return invoice;
}There are exceptions. If you have explicit, well-documented permission levels (like "viewer" vs "editor"), you might return 403 to indicate a permission issue rather than non-existence. But the default pattern for "you can't see this" is 404.
Test authorization with two different accounts
Authorization bugs hide in plain sight because developers test with their own user. You need to test with at least two separate accounts to catch these issues. Create a second test user, obtain valid tokens for both, and verify that neither can access the other's data.
I make this a mandatory practice: before merging any code that touches user data, I write a test that creates two users, gives each some data, and verifies that User A cannot read or modify User B's records.
// Example test with two accounts
describe('Invoice authorization', () => {
let user1Token: string;
let user2Token: string;
let user1Invoice: Invoice;
beforeEach(async () => {
// Create two separate users
const user1 = await createTestUser('user1@example.com');
const user2 = await createTestUser('user2@example.com');
user1Token = generateToken(user1.id);
user2Token = generateToken(user2.id);
// User 1 creates an invoice
user1Invoice = await createInvoice(user1.id, {
amount: 1000,
description: 'Consulting work'
});
});
it('should deny access to another user\'s invoice', async () => {
// User 2 tries to fetch User 1's invoice
const response = await request(app)
.get(`/api/invoices/${user1Invoice.id}`)
.set('Authorization', `Bearer ${user2Token}`);
// Should not expose the invoice
expect(response.status).toBe(404);
expect(response.body).not.toHaveProperty('amount');
});
it('should allow user to fetch their own invoice', async () => {
// User 1 fetches their own invoice
const response = await request(app)
.get(`/api/invoices/${user1Invoice.id}`)
.set('Authorization', `Bearer ${user1Token}`);
expect(response.status).toBe(200);
expect(response.body.amount).toBe(1000);
});
it('should deny updates from unauthorized users', async () => {
// User 2 tries to update User 1's invoice
const response = await request(app)
.patch(`/api/invoices/${user1Invoice.id}`)
.set('Authorization', `Bearer ${user2Token}`)
.send({ amount: 5000 });
expect(response.status).toBe(404);
// Verify the data wasn't changed
const invoice = await getInvoice(user1Invoice.id);
expect(invoice.amount).toBe(1000);
});
});This pattern catches ownership bugs early. Run these tests in your CI/CD pipeline alongside your other authorization tests. If you find a bug, add a test for it before fixing the code.
Common mistakes
I've seen several recurring patterns that lead to authorization bypasses:
- Checking ownership in the frontend only. Frontend checks are cosmetic. An attacker with access to your API documentation or network traffic can craft requests directly, bypassing all frontend logic.
- Forgetting to check on write operations. Developers are often more careful with GET requests than with PATCH or DELETE. A missing ownership check on an update endpoint is just as dangerous. Always check before reading, writing, or deleting.
- Assuming role-based access control is enough. RBAC tells you what a user can do in general ("admins can delete users"). It doesn't tell you if this specific user can delete this specific user. You still need resource-level checks.
- Not testing with multiple accounts. If you only test with one user account, you won't catch cross-user access bugs. Mandate multi-account authorization tests in your code review process.
- Leaking resource existence. Returning 403 instead of 404 tells attackers which IDs are valid. Normalize your error responses to avoid leaking metadata about your database.
- Checking ownership too late. If you load the full record, process it, and then check ownership, you've already burned CPU and I/O. Filter at the query level, before fetching data.
Key takeaways
- Every resource access—read, write, or delete—must check that the current user or tenant owns the resource. Never assume the client is honest about IDs.
- Filter by user or tenant ID at the database query level, not in application code. This is faster and reduces the chance of accidental leaks.
- Return 404 Not Found when a user tries to access a resource they don't own. This prevents attackers from enumerating valid resource IDs.
- Require authorization tests with at least two separate accounts. If you only test with one user, you'll miss cross-user access bugs.
- Make ownership checks automatic. Treat them as a standard part of every data access operation, not an afterthought.
- Apply the same rigor to all HTTP methods. GET, POST, PATCH, DELETE—all require ownership verification.






Comments
Loading comments…