The Cost of a 200 OK with Wrong Data
In a Business-to-Business Software-as-a-Service (B2B SaaS) application, the most damaging bug isn't a server error (500). It's a successful response (200 OK) that erroneously returns someone else's sensitive data. This isn't just a technical failure; it's a catastrophic breach of trust and a potential regulatory nightmare. For companies like malibou, an HRIS and payroll provider built on Next.js with Clerk for authentication, this risk is paramount. Every data record belongs to a single company, and strict isolation is non-negotiable. Maintaining this boundary across hundreds of routes, with a growing team and the increasing involvement of AI coding agents, is a significant challenge.
Access control is fundamentally divided into two distinct but related categories:
Vertical vs. Horizontal Access Control
Vertical Access Control (Function Level Authorization) answers the question: "Is this user allowed to perform this specific action?" This is typically handled through explicit permission checks. For instance, can a standard employee access the payroll processing function, or only an administrator? This layer focuses on the capabilities inherent to a user's role or assigned permissions.
Horizontal Access Control (Object Level Authorization) addresses the question: "Is this user allowed to access or modify this specific data record?" This is crucial when multiple users or entities share access to a system but must be confined to their designated data. In a multi-tenant SaaS, this means ensuring a user from Company A cannot see or alter data belonging to Company B. This is the more insidious threat, as it might not trigger obvious error messages but silently exposes confidential information.
The Challenge of Scale and Complexity
As applications grow, so does the complexity of access control. The initial implementation might be straightforward, with checks embedded directly in route handlers or API endpoints. However, as the codebase expands, developers might:
- Forget to implement checks on new routes.
- Implement checks inconsistently, leading to subtle bypasses.
- Introduce logic errors in the permission checks themselves.
- Rely on implicit assumptions about data ownership that are not enforced.
The introduction of AI code generation tools exacerbates this. While AI can accelerate development, it lacks the deep contextual understanding of business requirements and security implications that human developers possess. An AI might generate a perfectly functional API endpoint without understanding that it needs to be scoped to the authenticated user's tenant or company ID.
Leveraging Linters for Proactive Enforcement
This is where custom linter rules become invaluable. Linters are static analysis tools that identify programming errors, stylistic errors, and suspicious constructs in source code. By creating custom rules, developers can proactively enforce access control patterns directly within their development workflow, catching potential issues before they reach production.
Designing Effective Custom Linter Rules
The goal is to make common access control patterns explicit and difficult to miss. For horizontal access control, a critical pattern is the inclusion of a tenant or company identifier in data fetching queries. Consider a typical Next.js API route that fetches user data:
// Example of a vulnerable route
app.get('/api/users/:id', async (req, res) => {
const { id } = req.params;
const user = await db.users.findUnique({ where: { id } }); // Missing tenant check!
res.json(user);
});
A linter rule could flag this query because it lacks a `where` clause that filters by the authenticated user's company ID. The rule would look for specific database query patterns (e.g., `findUnique`, `findMany`, `update`) and check for the presence of a tenant identifier in the filtering conditions.

The rule could be configured to expect a specific parameter name (e.g., `companyId`, `tenantId`) or a specific variable holding this value, such as `req.auth.companyId` if using Clerk or a similar authentication provider.
Rule Example: Mandating Tenant ID in Queries
Imagine a rule that checks for object-relational mappers (ORMs) like Prisma or TypeORM. The rule would parse the Abstract Syntax Tree (AST) of the code. It would identify calls to methods like `prisma.user.findUnique`, `prisma.post.findMany`, or similar operations. For each identified call, it would inspect the arguments, specifically looking for a `where` clause. If a `where` clause exists, it would then check if it contains a condition related to a tenant or company identifier. If the condition is absent, the linter flags the line as a violation, providing a helpful message like:
Access control violation: Database queries must include a tenant/company ID filter to prevent data leakage. (rule: require-tenant-filter)
This approach turns a potential security blind spot into a mandatory code review item. Developers are forced to consciously consider and implement the tenant scoping, or the build process will fail.
Handling Vertical Access Control
Custom linters can also enforce vertical access control. For example, you might have specific functions that are only meant for administrators. A rule could check for the import and usage of sensitive administrative functions, ensuring they are always wrapped in an authorization check.
// Example of a vulnerable admin function call
import { deleteUserAccount } from '../adminUtils';
app.delete('/api/users/:id', async (req, res) => {
const { id } = req.params;
await deleteUserAccount(id); // Missing admin check!
res.sendStatus(204);
});
A linter rule could be configured to recognize the `deleteUserAccount` function (or any function within an `adminUtils` module). It would then scan the surrounding code in the route handler to ensure that a check like `if (!req.auth.isAdmin) { throw new ForbiddenError(); }` precedes the call. If the check is missing, the linter throws an error.
Implementation Considerations
Popular linting tools like ESLint offer robust plugin architectures that allow for the creation of custom rules. For a Next.js application, you would typically:
- Install ESLint if not already present.
- Create a custom ESLint plugin. This involves defining rules within a JavaScript or TypeScript module.
- Write the rule logic. This often involves using AST parsers (like Acorn for JavaScript or ESTree parsers) to traverse and analyze the code structure.
- Configure ESLint in your project's `.eslintrc.js` (or similar) file to enable your custom plugin and rules.
- Integrate into CI/CD. Ensure your linter runs on every commit or pull request to catch issues before merging.
The surprising detail here is not the complexity of writing these rules, but how much confidence they can instill. A few well-crafted rules can act as a vigilant security guard, patrolling your codebase for the most common and dangerous access control oversights.
What’s Next?
While linters are powerful, they are not a silver bullet. They enforce patterns and catch common mistakes. However, complex, dynamic authorization logic or nuanced business rules might still require runtime checks. The ideal approach combines static analysis with runtime enforcement. Nevertheless, for the vast majority of data scoping and permission checks, custom linter rules provide an exceptionally effective, low-friction method to significantly harden a B2B SaaS application against critical data access vulnerabilities. If you're building a multi-tenant application, start defining your core access control patterns and translating them into linter rules today. Your future self, and your customers, will thank you.
