← all posts

The tenant came from a header: EF Core query filters and the value behind them

EF Core global query filters made tenant isolation in my HR prototype look solved. The filters were fine. The value feeding them fell back to a request header and a hard-coded default. How I traced it, and how to fix it.

HR Agentic is a prototype I built: a recruiting and HR platform where many organizations share one PostgreSQL database. Jobs, candidates, interviews, employees, offers, audit logs, all in the same tables, separated by an OrganizationId column. The backend is ASP.NET Core with EF Core and Npgsql, the front end is Next.js 14.

Isolation is done with EF Core global query filters, and when I reviewed the code the filters were exactly right. Every tenant-owned entity had one, and no service bypassed them. The problem was one file over, in the small class that tells the filter which organization it is looking at.

In short:

What global query filters do well

A global query filter is a predicate EF Core adds to every query for an entity type. In this project the model configuration looks like this, repeated for each tenant-owned table:

modelBuilder.Entity<Candidate>(entity =>
{
    entity.ToTable("candidates");
    entity.HasQueryFilter(x => x.OrganizationId == _tenantContext.OrganizationId);
});

_tenantContext is a scoped service injected into the DbContext. EF Core treats the reference to a field on the context as a parameter, so the same compiled query runs with a different organization id for each request.

Fourteen of the seventeen entity sets carry this filter. The three that don’t are organizations, users, and billing plans, and each has a reason: sign-in has to find a user by email before it knows their organization, and billing plans are a catalog every organization shares.

What this buys you is a safe default:

HTTP requestTenant contextresolves org idQuery filterWHERE org = idPostgreSQLrows of one org
fig. 1 / what the filter does on every query. the third box is only as good as the second.

That is the part that looked solved. But the filter never asks where the organization id came from. It compares a column to whatever the tenant context returns, and it will do that just as faithfully for a forged value as for a real one.

The question the filter can’t answer

Here is the tenant context, lightly simplified. The only change from the prototype is that I replaced the literal GUID with a name:

// Simplified from the prototype. The hard-coded GUID is replaced by a name.
private static Guid ResolveOrganizationId(IHttpContextAccessor accessor, IConfiguration config)
{
    var http = accessor.HttpContext;

    // 1. The signed-in user's organization claim
    if (http?.User?.Identity?.IsAuthenticated == true &&
        Guid.TryParse(http.User.FindFirst("org_id")?.Value, out var fromClaim))
        return fromClaim;

    // 2. Any request header called X-Tenant-Id
    if (http?.Request.Headers.TryGetValue("X-Tenant-Id", out var header) == true &&
        Guid.TryParse(header.ToString(), out var fromHeader))
        return fromHeader;

    // 3. A configured default
    if (Guid.TryParse(config["Tenant:DefaultOrganizationId"], out var fromConfig))
        return fromConfig;

    // 4. The seeded demo organization
    return DemoOrganizationId;
}

Each line has a plausible reason to exist. A header is handy when you are calling the API by hand. A default means nothing crashes when there is no user. But put together, they describe a tenant context that never says “I don’t know”. It always returns an organization, and only the first rung checks who is asking.

Signed in with an org_id claim?yesOrganization from the cookiewhere data endpoints stopnoX-Tenant-Id header is a GUID?yesOrganization the caller namesno authentication involvednoDefault set in configuration?yesOrganization from confignot set in this reponoNothing matchedHard-coded demo organizationthe seeded demo tenantanonymous requests, startup code and anything without a user fall through to rungs 2-4
fig. 2 / how the tenant context picked an organization. every rung below the first is reachable without signing in.

The configured default is not set anywhere in the repository’s configuration, so in practice rung 3 is skipped and rung 4 decides. The hard-coded value is the same id the database seeder uses for the demo organization it creates on first start.

Tracing what actually happens

My first note on this read “header fallback allows cross-tenant reads”. Before writing that down as a finding, I traced every way a request can reach a filtered query. The answer was more specific than the note.

Reads through the data endpoints are not open today. Every data controller (jobs, candidates, interviews, employees, dashboard, platform) carries [Authorize], and authentication is a cookie. A request without a valid cookie never reaches a service. And every sign-in path builds its claims in one method that always adds org_id from the user’s record. The cookie is protected by ASP.NET Core data protection, so a client cannot edit the claim. Put together: any request that reaches a data endpoint has a valid org_id claim, rung 1 wins, and the header is ignored.

Writes through the anonymous endpoints do reach the fallback. The auth controller has anonymous endpoints (register, login, accept invite, password reset), and they all write audit log entries. The audit service stamps each entry with tenantContext.OrganizationId.

The clearest case is the password reset request. It is anonymous, it never signs anyone in, and when it finds an active user with the given email, it writes an audit entry recording that a reset was requested for that user’s id. At that moment there is no authenticated user, so the tenant context goes down the ladder:

Anonymous POSTreset requestAudit writeneeds an org idTenant resolveno user: headerWrong org's logshown on overview
fig. 3 / the live path: an anonymous request decides which organization's audit log gets the entry.

The platform overview endpoint returns each organization’s ten most recent audit entries. So a reset requested for a user in organization A can show up in organization B’s activity feed, with A’s user id in it. And anyone who knows a user’s email can drop entries into the audit log of any organization whose id they have. It is a small leak and a log-integrity problem, not a data breach. But it is real, and it exists only because of rungs 2 and 4.

The read path is closed by accident, not by design. That is the bigger finding. Isolation for reads depends on two facts that live in other files: an attribute on each controller, and a claim added in a sign-in helper. Change either and the filter silently starts scoping to a header or to the demo organization:

None of these would throw. The query would run, return rows from the wrong organization, and look perfectly normal.

The fixed design

The rule I wanted is short: the organization comes from the authenticated principal, or the request does not touch tenant data. In this app the principal comes from the auth cookie; with bearer tokens it would be the validated token. Either way it is something the server signed, not something the client typed.

That turns into four changes:

  1. One source. The tenant context reads org_id from an authenticated principal and nothing else.
  2. No default. If there is no principal or no claim, it throws. A query filter that evaluates it throws with it, so no query runs.
  3. No header. It is not a fallback, a debug mode, or a development-only switch. It is gone.
  4. Explicit tenant for work without a user. Anonymous endpoints that need an organization pass the one from the record they already loaded. Seeding and jobs say which organization they act for.
// Simplified: one source, no fallbacks.
public sealed class TenantContext(IHttpContextAccessor accessor) : ITenantContext
{
    public Guid OrganizationId
    {
        get
        {
            var user = accessor.HttpContext?.User;
            if (user?.Identity?.IsAuthenticated == true &&
                Guid.TryParse(user.FindFirst("org_id")?.Value, out var orgId))
            {
                return orgId;
            }

            throw new TenantUnresolvedException();
        }
    }
}

Whether that exception reaches the client as a 401 or a 500 matters much less than the fact that no query ran. A 500 here means a code path forgot to establish a tenant, which is a bug you want to hear about, not paper over.

The password reset already has the user it is talking about, so it knows the right organization:

// Simplified: the anonymous endpoint names the organization explicitly.
await auditService.LogAsync(
    organizationId: user.OrganizationId,
    category: "auth",
    action: "password-reset-request",
    entityId: user.Id.ToString());

The seeder does not need the default either. It only queries the organizations table, which has no filter, and it sets OrganizationId explicitly on every row it creates.

missingAuth cookieserver-signedorg_id claimrequiredTenant contextor throwQuery filterWHERE org = idFail closedno query runs
fig. 4 / the fixed design. one source, and a missing tenant stops the request instead of guessing.

Tests that prove isolation

The prototype has no test project yet, so these are the first tests I would add. They are written against the boundary, not the happy path: each one tries to cross it.

// Simplified.
[Fact]
public void Header_alone_does_not_pick_a_tenant()
{
    var http = new DefaultHttpContext();
    http.Request.Headers["X-Tenant-Id"] = Guid.NewGuid().ToString();
    var tenant = new TenantContext(new HttpContextAccessor { HttpContext = http });

    Assert.Throws<TenantUnresolvedException>(() => tenant.OrganizationId);
}

[Fact]
public async Task Signed_in_user_cannot_read_another_org_even_with_the_header()
{
    var (orgA, orgB) = await SeedTwoOrganizationsAsync();
    var client = await SignedInClientAsync(orgA.AdminEmail);
    client.DefaultRequestHeaders.Add("X-Tenant-Id", orgB.Id.ToString());

    var response = await client.GetAsync($"/api/candidates/{orgB.CandidateId}");

    Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

The rest of the set, in one line each:

Two organizations is the minimum fixture for any of this. With one, every isolation test passes trivially.

What I would do differently

  1. No fallbacks in anything that decides identity. A default tenant is a decision made on behalf of a request that never made one. “I don’t know” has to be a possible answer, and it has to stop the request.
  2. Trust the filter, audit the source. Query filters did their job perfectly. The review that matters is the five lines that feed them.
  3. Trace before you file. My first note overstated the read risk and missed the write path. Tracing gave me the real, smaller bug and the more important structural one.
  4. Don’t let safety live in three files. Reads were safe because of an attribute on each controller and a claim added in a sign-in helper, far from the tenant code. The fixed tenant context is safe on its own.
  5. Test with two tenants from day one. Isolation bugs are invisible with one.

If you are building multi-tenant systems on EF Core and want to compare notes on where your tenant comes from, you can reach me through my site.