Clinic A and Clinic B share one codebase, two subdomains, and a mix of office and VPN visitors. Network teams tend to configure the two entrances separately, which is fine, as long as the application does not. The checks below belong in the application and should behave identically no matter which door the request used.

First: the hostname only names a tenant

Resolve the requested hostname against a mapping the administrator maintains, and reject anything unknown. Use the framework's validated host value instead of the raw header, so a crafted Host cannot inject a lookup key. A successful match tells the code which tenant configuration and branding to load. It says nothing about whether the caller may see that tenant's data. Keep that second question in a separate function with a separate input.

A shared helper that returns both context and permission from one hostname lookup is the beginning of the bug.

Second: membership comes from the session

Look up the authenticated user's active memberships and compare them with the tenant from check one. A user who belongs to both clinics may switch, but the switch should be an explicit action recorded in the request context, not an inference from the URL. A user who belongs to one clinic and requests the other is denied, including for downloads and API calls.

Test the negative case by editing a record identifier and a tenant parameter. If either change returns data, the scope is being read from the request rather than from the verified membership.

Cookie scope and single sign-on decide where an account can be presented. They do not decide what that account may read.

Third: the network proves nothing about the person

A VPN gateway authenticates a tunnel, and a public allowlist authenticates an address range. Neither identifies a person. NAT collapses many users into one source address, and an office range does the same for everyone in the building. Treat both as transport facts and keep authorization in the application, or in an integration that asserts identity explicitly and is reviewed as such.

DNS is a third transport fact. A private answer needs a private route, and a public front end needs a supported origin path. None of them carries a permission.

One sentence to keep on the wall: the network decides how a request arrives, and the application decides what it may read.

Fourth: run the matrix on both entrances

Use controlled accounts to exercise A at A, B at B, A at B, an unknown hostname, and an authenticated account with no memberships. The fourth and fifth should fail closed, never defaulting to a tenant. Run the same matrix through the VPN entrance as well, with one session jar per account:

curl -s -o /dev/null -w '%{http_code}' -b jar.txt https://clinic-b.example.com/records/42
curl -s -o /dev/null -w '%{http_code}' -b jar.txt https://clinic-a.example.com/records/42

Then change an allowlist entry and rerun. A result that moves means a network control is making an authorization decision.

Repeat the matrix after any front-end change, because a second code path that skips the check is the most common way this boundary erodes. The expected result is boring: every entrance produces the same answers.

Ũhoro ũhĩtĩte