I built TenantLint, an open-source Roslyn analyzer that catches cross-tenant data leaks in .NET at build time: in your IDE, in CI, and inside your AI agent's build loop.
The most dangerous bug in a multi-tenant SaaS doesn't crash anything. There's no exception, no failing test, no red dashboard. A customer just opens a page and sees another customer's invoices.
I've spent 22 years writing software and the last decade as CTO of a B2B SaaS product. We are moving parts of our platform to a shared PostgreSQL database, where every tenant's rows live in the same tables. That move made me look hard at one question: in a codebase where AI agents now write a large share of the code, what stops a single missing filter from reaching production?
The honest answer was: code review, and luck. So I built a tool for it.
The bug that hides in plain sight
Most EF Core multi-tenant apps isolate tenants with a global query filter. You write it once, and every query on that entity gets WHERE TenantId = @current automatically:
modelBuilder.Entity<Customer>()
.HasQueryFilter(c => c.TenantId == _tenantId);
It's a great pattern, until someone needs to see soft-deleted records:
var customers = await db.Customers
.IgnoreQueryFilters() // removes soft-delete... and the tenant filter
.ToListAsync();
IgnoreQueryFilters() removes every filter on the entity, not just the one you were thinking about. This line compiles, passes code review, and passes your tests, because your tests almost never run with two tenants in the same database. In production, it returns every customer of every tenant.
There are a dozen variations of this bug. Raw SQL skips global filters entirely. A filter written slightly wrong gets baked into EF Core's cached model. A tenant id read from a request header lets the client choose whose data to see. Each one looks like perfectly normal code.
Why AI coding agents raise the stakes
AI agents like Claude Code, Codex, Cursor and Copilot are genuinely good at writing EF Core code. That's exactly the problem: the code they write looks right.
An agent asked to "also show archived customers" will very reasonably reach for IgnoreQueryFilters(). An agent asked to speed up a report will write raw SQL. An agent that keeps hitting a warning it doesn't understand may simply suppress it. None of this is malicious; it's the shortest path to a green build.
But agents have one habit that works in our favor: they run dotnet build constantly, and they read the output. A warning that says what leaks, why, and exactly how to fix it is something an agent can act on in its next step, without any extra setup, plugin or MCP server.
That became the design principle for TenantLint: put the check where the agent already looks.
What TenantLint does
TenantLint is a set of Roslyn analyzers. You add one NuGet package, with no framework migration, no base classes and no configuration:
dotnet add package TenantLint.Analyzers
It reads your DbContext, works out which entities are tenant-scoped and how they are filtered (plain EF Core HasQueryFilter, Finbuckle.MultiTenant or ABP), and then checks every build. For the snippet above, the build output says:
warning TL001: 'Customer' query removes the tenant filter
via IgnoreQueryFilters(). Use IgnoreQueryFilters(["SoftDelete"])
to lift only the filter you need, or mark the method
[CrossTenant("reason")].
Notice the "SoftDelete". TenantLint found the name of your other filter and suggests EF Core 10's selective overload, which keeps tenant isolation in place. In Rider or Visual Studio it's a one-click code fix:
var customers = await db.Customers
.IgnoreQueryFilters(["SoftDelete"]) // tenant filter stays on
.ToListAsync();
The message is written for two readers: a developer skimming the IDE, and an AI agent reading build output. Both get the what, the why and the fix in one line.
Four leaks that look like correct code
TenantLint ships with 27 diagnostics. These four surprised me the most while building it, and each was confirmed with a two-tenant runtime test before I wrote the rule.
1. A filter value that gets cached forever (TL012)
public static class TenantContext
{
public static Guid Current { get; set; }
}
modelBuilder.Entity<Order>()
.HasQueryFilter(o => o.TenantId == TenantContext.Current);
This looks fine and even works in a quick test. But EF Core builds the model once and caches it. Values that don't come from the DbContext instance are evaluated when the model is built and then reused. In my test, the first tenant's id was baked into the cached model, and every later request saw that tenant's orders. The safe version reads the value through the context instance (a field, a property, or a primary-constructor parameter).
2. A pooled DbContext that remembers the last tenant (TL011)
AddDbContextPool reuses context instances across requests, and the constructor only runs once per pooled instance. If the tenant id is captured in the constructor, the next request can inherit the previous tenant. TenantLint flags this at the registration site and points to the safe pattern: set the tenant after the context is taken from the pool.
3. An entity bound straight from the request body (TL010)
app.MapPost("/customers", (Customer c, AppDb db) => { db.Add(c); /* ... */ });
Query filters protect reads, not writes. If the client sends "tenantId": "someone-else" in the JSON, this writes into another tenant. TenantLint asks for a DTO, with the tenant set from the authenticated user.
4. A tenant id the client chooses (TL004)
var tenantId = Request.Headers["X-Tenant-Id"];
Headers, query strings and form fields are user input. Tenant identity should come from the authenticated principal. Route parameters like /orgs/{orgId} are a legitimate pattern when they're authorized against the user's claims, so TenantLint reports those as Info (TL104) rather than Warning.
The full list covers raw SQL in EF Core, Dapper and ADO.NET, cache keys without a tenant, static tenant state, background jobs, PostgreSQL row-level security session variables, and ABP and Finbuckle specific bypasses.
Stopping the agent from silencing the analyzer
There's an obvious way around any analyzer: suppress it. For an agent trying to reach a green build, this is often the shortest path:
#pragma warning disable TL001
var all = db.Customers.IgnoreQueryFilters().ToList();
So TenantLint watches for that too. TL201 flags #pragma warning disable for a TenantLint rule without a justification comment. TL202 does the same for [SuppressMessage], and TL203 reports when TenantLint rules are switched off project-wide through NoWarn or .editorconfig. Placeholder justifications like "TODO" or "n/a" count as missing.
Some code really does need to cross tenants: admin reports, maintenance jobs, lookups before authentication. For those, TenantLint recognizes a [CrossTenant("reason")] attribute, which you define in your own codebase in three lines (TenantLint adds no runtime dependency). The reason is required, so every exception leaves a reviewable trail in the code.
I also recommend one line in your agent instructions file (CLAUDE.md, AGENTS.md or copilot-instructions.md):
Treat TLxxx diagnostics as errors; never suppress them without a
[CrossTenant(reason)]attribute.
How I tested it, honestly
A security analyzer lives or dies by its false positives. If the first build shows twenty warnings and half are wrong, people uninstall it the same day. So before the stable release, I ran TenantLint against real open-source multi-tenant .NET projects.
The first round was humbling: 56% of the findings were false positives. Every one of them came from frameworks that apply tenant filters where static analysis can't see them. Finbuckle adds a shadow TenantId and its filter at runtime, and ABP sets up filters inside its own base DbContext. Teaching TenantLint to recognize both brought Warning-level false positives on those projects down to zero, and it also made it catch bugs it had been blind to.
Then I tested on projects it had never seen. That surfaced one new pattern, filters registered through reflection, which TenantLint now reports as "can't verify" (Info) instead of a Warning. A real-world test of my own also caught a false positive on C# primary-constructor parameters, fixed before 0.1.0.
Along the way it found genuine gaps in real code: unfiltered IgnoreQueryFilters() calls, and tenant-owned entities with no model-level filter that relied on every query remembering a manual WHERE.
To be clear about the limits: these were six projects, and some of them are small. That's a sanity check, not a benchmark. The full write-up, including what was missed, is in the repository's docs/validation folder. If you see a false positive, there's an issue template for it, and I'll treat it as a bug.
What it is not
TenantLint does not guarantee tenant isolation. It's static analysis: it reads code, not runtime behavior, and it reports suspicious patterns, not proven vulnerabilities. It's built for shared-database (row-level) multi-tenancy on EF Core 8+ and .NET 8+. Database-per-tenant and schema-per-tenant setups, EF6 and .NET Framework are out of scope.
Think of it as one layer. Pair it with database-level protection such as PostgreSQL row-level security, and with tests that run real requests as two different tenants.
Try it
dotnet add package TenantLint.Analyzers
It's MIT-licensed, has zero runtime dependencies, and works in Rider, Visual Studio, VS Code, CI, and any AI agent that runs dotnet build.
If you run a multi-tenant .NET app, I'd love to hear what it finds, and especially what it gets wrong. False-positive reports, new leak patterns and contributions are all welcome; several planned ideas are open as issues for first-time contributors.
AI agents are going to write more and more of our code. Making the build itself tell them when they've crossed a tenant boundary seems like one of the cheapest safety nets we can add.

Top comments (0)