Skip to main content
Dejan Demonjić brand logoDejan Demonjić
Decision log
Work
About
Contact

Decision log / Decision record

A Content Security Policy for an identity server

The strictest policy a sign-in page can carry, which directive is left out and why, what base-uri 'none' does to a Blazor page, and why the policy is chosen from the endpoint. A design note from building Hexavera's identity host.

Filed
2026-10-16
Topic
Identity and security
Reading
6 min
Tags
#csp#blazor#oauth#security

Part 4 of 5 Identity and access

  1. One identity server for the cloud and for every site
  2. Decide from the endpoint, not from the path
  3. When the token store is down, the honest answer is 503
  4. A Content Security Policy for an identity server
  5. Mutual TLS and certificate-bound tokens for the links that matter
Previous When the token store is down, the honest answer is 503Next Mutual TLS and certificate-bound tokens for the links that matter

The claim

An identity server's pages can carry a Content Security Policy that allows almost nothing, and they should. Two things about that policy are not obvious from the specification: the form-action directive behaves differently across browser engines on the redirects that every OAuth sign-in depends on, and base-uri 'none' changes how a Blazor page must address its assets. Both have to be decided, and the decision has to be tested in a real browser.

The policy

Hexavera's identity host serves account pages (sign-in, consent, profile, security) and the protocol endpoints. The account pages run no script and use no inline style, by decision, so the policy allows neither:

Content-Security-Policy: default-src 'none'; style-src 'self'; img-src 'self';
                         object-src 'none'; base-uri 'none'; frame-ancestors 'none'

The two pages that hold a passkey ceremony load one module from the host and talk to the host, and only they add script-src 'self'; connect-src 'self'. No page allows inline script or another origin.

OWASP ASVS 5.0.0 requirement 3.4.3 (level 2) asks that "as a minimum, a global policy must be used which includes the directives object-src 'none' and base-uri 'none' and defines either an allowlist or uses nonces or hashes". The policy above is the allowlist form, with the allowlist reduced to the host itself.

The directive left out

The specification defines form-action as the directive that "restricts the URLs which can be used as the target of a form submissions from a given context" (CSP Level 3, section 6.4.1). On an identity server, form-action 'self' reads as the natural choice: the only form target is the host.

The pre-navigation check in section 6.4.1.1 matches the request's current URL, with its redirect count, against the directive, and the HTML Standard runs that check again for every redirect a navigation follows, so by the specification each hop after the submission is checked. Whether it should be is still debated, and the browsers do not agree: the working group's issue "CSP: form-action and redirects" has been open since 2015, and MDN's note on the directive records that "Firefox 57 doesn't block the redirects whereas Chrome 63 does".

An OAuth sign-in is a chain of redirects that starts with a form submission: the credentials are posted to the sign-in page, the server redirects to the authorization endpoint, and the authorization endpoint redirects to the client's registered address, which is another origin here, because the identity host has a hostname of its own. In a Chromium-based browser, form-action 'self' is applied to each of those hops, so the last one is refused and the person never arrives at the application. It was measured in Edge on 2026-10-02 while the host was being built.

So the policy carries no form-action. That is an accepted risk, written into the threat model with its reasoning, and it comes with a compensating control: what form-action would guard against is injected markup posting the form elsewhere, and the account pages are kept free of raw markup by a test rule that scans the host's components and code for four constructs that bypass Razor's encoding (MarkupString, AddMarkupContent, @Html.Raw, HtmlString) and its scripts for markup and eval sinks, and fails on any it finds. The alternative still on the list is a per-response policy whose form-action names the validated client's origin; it is feasible, but every future page that ends at a client would have to remember to declare that origin, and a forgotten one would break sign-in for Chromium users only, where in-process tests cannot see it, so it waits for a reason.

What base-uri 'none' does to a Blazor page

base-uri "restricts the URLs which can be used in a Document's base element" (CSP Level 3, section 6.3.1). With 'none', any <base> element with an href is a violation, including the <base href="/" /> that the .NET 10 Blazor Web App template puts in App.razor (the .NET 11 template renders it through a BasePath component instead). Blazor's fingerprinted asset paths are written relative to that base: @Assets["app.css"] renders a relative address. Without a working <base>, a page served at /account/sign-in resolves that address under its own path and renders unstyled.

The host therefore has no <base> element, and every asset reference is root-relative: / prefixed to the fingerprinted path. A test renders the sign-in and signed-out pages and asserts that every stylesheet and image reference starts with / and loads; a passkey test asserts that the sign-in page's script is addressed from / too. This site keeps its <base href="/"> because it carries no base-uri directive; the two choices are consistent, and each is written down.

The policy is chosen from the endpoint

Which pages carry the passkey variant of the policy is a per-endpoint decision, and it is made the way the second note in this series describes: the two pages mark themselves with an attribute, and the header middleware reads the attribute from the endpoint routing chose when the response starts. The policy was once chosen by comparing the path, and one spelling of the sign-in page received the strict policy, which refused the passkey module. Choosing from the endpoint means every spelling routing sends to those pages receives the right policy, and an error page re-executed for them does not.

What it cost

  • A test rule over the account pages' markup, maintained alongside them.
  • Root-relative asset paths and a test that guards them.
  • A threat-model row that says, in writing, that form-action is absent and why.

What a team can take from this

Treat a Content Security Policy as a feature with browser-specific behaviour, not as a header you set once. Drive the real flows through a Chromium-based browser and through Firefox before you ship a directive, because the engines do not all implement the specification the same way. When a directive breaks a flow you need, do not weaken the whole policy; remove the one directive, write down the risk you accepted, and put a compensating control where a reviewer will find it.

DD

Dejan Demonjić

Principal software engineer, Belgrade

Designs and builds secure, multi-tenant .NET platforms. Questions about this entry, or a different view on it, are welcome by email.

  • Email
  • LinkedIn
  • GitHub
Previous One repository, several solutionsSplitting a product line into repositories is a permissions decision wearing an architecture costume. Azure Repos permissions stop at the repository, so if nobody has to be kept from reading part of the code, one repository with several solution files keeps the compile-time safety that packages trade for version drift. The decision record, the reference matrix, and the test that holds it.Next When the token store is down, the honest answer is 503A resource server that cannot check a token must refuse it, and a 401 is the wrong refusal: it tells the client its token is bad and signs the user out. The design: fail closed, classify the fault by what the data provider says, answer 503 with a Retry-After drawn per response, and let anonymous routes ask before they say 'not signed in'.

In this entry

  1. The claim
  2. The policy
  3. The directive left out
  4. What base-uri 'none' does to a Blazor page
  5. The policy is chosen from the endpoint
  6. What it cost
  7. What a team can take from this
Dejan Demonjić

Designing and building secure, multi-tenant .NET platforms, and writing down the decisions behind them.

Read

  • Decision log
  • Work
  • RSS feed

Contact

  • dejan@demonjic.com
  • Write to me
  • About

Elsewhere

© Dejan Demonjić. Written in Belgrade.