OriginHeaderResolver¶
SPA-friendly tenant resolver that reads the browser-set Origin HTTP header and matches it against a configurable allow-list. Sits in the resolver chain at priority 25 — above HeaderResolver (20), below HostResolver (30).
Overview¶
When a single-page app at https://acme.app.example.com makes a cross-origin XHR/fetch call to your API at https://api.example.com, the browser stamps the request with Origin: https://acme.app.example.com. OriginHeaderResolver reads that header, looks the origin up in an allow-list configured under tenancy.origin.allow_list, and resolves the tenant — no extra header, no query param, no subdomain routing required.
The resolver is opt-in: it is not in the default tenancy.resolvers list. Enable it by adding 'origin' to your resolver chain and configuring at least one allow-list entry.
Priority 25 means Origin wins over X-Tenant-ID (20) when both are set in the same request — see Mismatch Warning below — and loses to subdomain-based HostResolver (30) when both are configured.
Configuration¶
Add 'origin' to your resolvers list and define the allow-list:
# config/packages/tenancy.yaml
tenancy:
resolvers: ['host', 'header', 'origin']
origin:
allow_list:
# Explicit map form: pin a specific origin to a specific tenant slug.
- { origin: 'https://acme.app.example.com', slug: 'acme' }
- { origin: 'https://beta.app.example.com', slug: 'beta-customer' }
# Wildcard shorthand: leftmost label becomes the slug at runtime.
# `https://*.app.example.com` matches `https://anything.app.example.com`
# and resolves to tenant slug = `anything`.
- 'https://*.app.example.com'
Allow-list entry rules¶
| Rule | Why |
|---|---|
Origin must be an absolute URL scheme://host[:port] |
RFC 6454 — origins are bare authorities |
Scheme must be http or https |
Browsers only set Origin for these schemes |
Port defaults to 80 for http, 443 for https when omitted |
Normalized at compile time so runtime matching is exact-equality |
Exactly one * allowed, in the leftmost label only |
Mid-string wildcards (app.*.example.com) are silently permissive — rejected at compile time |
| No path, query, or fragment | Origins have no path component per RFC 6454 |
Non-wildcard entries require an explicit slug |
Wildcard entries derive slug from the matched label |
Invalid configurations fail at container compile time with a descriptive error naming the offending entry. There is no way to ship a misconfigured allow-list to runtime.
Trust Model¶
Origin is a browser-locked routing hint, not an authentication credential. Pair this resolver with your real auth layer.
Where the Origin header comes from¶
For cross-origin XHR, fetch(), and CORS preflight requests, the browser sets Origin itself. JavaScript running in the page cannot override Origin — it is in the "forbidden header name" list of the Fetch standard. This makes Origin a strong tenant-routing signal inside a browser context: a tab origin-locked to https://acme.app.example.com will always send that exact Origin value, regardless of what the page's JavaScript wishes.
Where the trust ends¶
Origin is trivially settable from non-browser clients:
curl -H 'Origin: https://acme.app.example.com' …works- Postman, Insomnia, HTTPie, native mobile (NSURLSession, OkHttp), server-to-server clients — all can forge
Originfreely - Bots and scrapers can spoof
Originto whatever value gets the response they want
Treat OriginHeaderResolver as a routing convenience for browser SPAs. Always pair it with your real authentication layer — Bearer tokens, cookies with CSRF protection, signed requests — for any endpoint that does anything sensitive. The resolver picks the tenant; your auth picks the user.
Failure-safe by default¶
Two design choices make misconfiguration impossible-to-ship rather than dangerous-at-runtime:
- Opt-in — Origin is not in the default resolvers list. Adding it is an explicit choice.
- Empty allow-list = compile error — A YAML file with
'origin'in resolvers but noallow_listentries fails at container build, not at runtime. There is no degenerate state where the resolver silently accepts every origin or silently rejects every origin.
A note on http://¶
Both http:// and https:// are permitted in allow-list entries because local SPA dev servers (http://localhost:3000, http://localhost:5173) need to route to a dev tenant. Mixing http:// and https:// origins in a production allow-list is a security smell — production traffic should be HTTPS-only and your allow-list should reflect that.
Mismatch Warning¶
When a request arrives with both a matching Origin header AND an X-Tenant-ID header whose slug differs from the Origin-resolved slug, OriginHeaderResolver:
- Resolves the tenant from
Origin(Origin wins because priority 25 > HeaderResolver's 20). - Emits a
warning-level PSR-3 log record so operators can detect routing-confusion attempts.
The log payload is intentionally structured:
{
"level": "warning",
"message": "Origin/X-Tenant-ID mismatch — Origin wins",
"context": {
"origin": "https://acme.app.example.com",
"origin_slug": "acme",
"header_slug": "beta",
"winner": "origin"
}
}
Wire this into your normal log pipeline; alert if the warning rate exceeds a baseline. Slug comparison is case-insensitive — acme and ACME are treated as the same tenant for the purposes of this check.
Examples¶
A React SPA at app.example.com calling an API at api.example.com¶
tenancy:
resolvers: ['origin']
origin:
allow_list:
- 'https://*.app.example.com' # any tenant subdomain
// In the SPA — origin is set automatically by the browser
fetch('https://api.example.com/users', { credentials: 'include' })
Two named tenants on the same multi-tenant SPA host¶
tenancy:
resolvers: ['origin', 'header']
origin:
allow_list:
- { origin: 'https://acme.app.example.com', slug: 'acme' }
- { origin: 'https://contoso.app.example.com', slug: 'contoso' }
Local development with a Vite dev server¶
tenancy:
resolvers: ['origin']
origin:
allow_list:
- { origin: 'http://localhost:5173', slug: 'dev-tenant' }
- 'https://*.app.example.com' # staging/prod still works
CORS preflight requests¶
OPTIONS requests are passed through the resolver chain without any Origin parsing — the resolver returns null immediately so preflight never throws or routes to a tenant context. Browser preflight semantics are preserved. Setting Access-Control-Allow-Origin is your application's responsibility (typically via nelmio/cors-bundle or Symfony's built-in CORS handling).