ZB Field Notes

Why /.well-known/security.txt matters, and how to serve it properly

Why /.well-known/security.txt matters, and how to serve it properly

There is one question a stranger needs answered about your site, and almost no site answers it: I found a vulnerability — who do I tell?

RFC 9116 is the answer. A flat text file at /.well-known/security.txt, containing a reporting address and an expiry date. That is the whole standard.

It opens no attack surface and closes none. What it does is remove an excuse. Without it a well-meaning researcher has to guess — LinkedIn DM, contact form, an address scraped off a commit — and a good number of them give up. Some skip straight to publishing. It is the web equivalent of the “in case of fire, call this number” sign: worthless right up until it is the only thing someone needs.

It is also easy to serve in a way that looks correct and is not. Here is what the file must contain, and the traps in delivering it — drawn from putting it on five hosts served by three Spring Boot applications.

Expires is a deadline, not a hint

This is the field most people get wrong, and it is mandatory.

RFC 9116 says a consumer must ignore an expired file. Not warn — ignore. So a security.txt that quietly lapses is worse than having none: it still returns a healthy 200, it still passes a shallow audit, and every scanner that reads it throws it away. You end up with a file, a green check, and no security contact.

Nothing in your infrastructure will remind you. A date a year out is exactly the kind of obligation that evaporates, so the only real defence is to put the alarm somewhere you cannot walk past. I put it in a test:

@Test
void defaultExpiryIsAtLeastThirtyDaysAway() {
    Instant expires = new CvNextProperties().getSecurityTxt().getExpires();

    assertThat(expires)
            .as("security.txt expires %s - bump CvNextProperties.SecurityTxt#expires", expires)
            .isAfter(Instant.now().plus(Duration.ofDays(30)));
}

Yes, this will one day fail a build that touched nothing related. That is the point. Thirty days of runway turns the renewal into a chore rather than an incident, and a red CI run whose message names the exact constant to change beats hearing about it from someone else’s scanner report.

Do not publish the address that guards things

The tempting move is to reuse whatever owner email the application already knows about. Resist it.

Most apps have at least one privileged identity — in mine, an owner email that gates the analytics dashboard and a private board. security.txt exists to be scraped. Publishing that address in a file whose entire purpose is machine discovery hands every crawler the identity your authorization checks compare against.

Give it a dedicated inbox, and pin the separation with a test so a later refactor cannot quietly “simplify” the two back into one:

@Test
void reportingAddressIsNotThePersonalOwnerInbox() {
    CvNextProperties properties = prodLikeProperties();

    String txt = new SecurityTxtController(properties).body();

    assertThat(txt).doesNotContain(properties.getOwnerEmail());
}

Serving it is a routing problem

Here is the trap that makes this more than a static file drop, and it bites hardest on exactly the modern setups most likely to care about security headers.

My deployment serves four hosts from one jar — an apex SPA, a server-rendered blog, a client-side CV builder, and a private kanban board — each selected by a servlet filter that inspects X-Forwarded-Host. Two of those are single-page apps, and like every SPA host their filter ends in a fallback: any unknown path is forwarded to index.html.

Not a 404. A 200 with an HTML body.

Drop a security.txt controller into that and it appears to work: the URL resolves, the status is green, and every RFC 9116 parser receives a page of markup. A malformed file is worse than a missing one — the missing one is at least honest. The blog host failed differently but no better, rewriting /x to /blog/x and 404ing on a path that does not exist.

So the fix was not the controller. It was one entry in the passthrough list of three filters:

private static final List<String> PASSTHROUGH_PREFIXES = List.of(
        "/focus", "/assets", "/api", "/oauth2", "/login", "/logout", "/error", "/actuator",
        "/.well-known");

A passthrough, deliberately, and not a forward target. These filters run after Spring Security, and Spring Security does not re-filter FORWARD dispatches — which is why the standing rule in this codebase is that forward targets must be static files carrying no data. Anything generated has to go back through the normal chain. Usefully, adding a prefix to a passthrough list only ever removes paths from the forward branch, so it cannot introduce an authorization bypass by construction.

The whole /.well-known prefix passes through, not just security.txt — it is an IANA registry, not an application route. Side effect worth having: unknown well-known paths now return a clean 404 instead of an SPA shell at 200, and there is a place for assetlinks.json or an ACME challenge later.

Check your own stack for the equivalent. Nginx try_files ... /index.html, a Netlify catch-all rewrite, a React Router wildcard on the server — they all turn a missing file into a 200 of HTML.

Canonical is plural, and it is a trust check

If you serve several hosts, do not scatter near-copies that drift. Since all four of mine are the same process, they serve byte-identical content — which makes the Canonical field load-bearing. RFC 9116 §2.5.2 says a consumer should treat the file with suspicion if the URI it fetched is not listed, so every URI it answers on has to appear:

Browser showing the live security.txt at zakaria.lu with Contact, Expires, Preferred-Languages and four Canonical lines
The live file. The four Canonical lines are generated from the same enable flags the host filters read, so a switched-off host can never be advertised.

Generating that list from configuration rather than typing it out is what stops it drifting away from where the file is genuinely reachable.

For extra hosts, redirect rather than copy

My fifth host is an MCP server — a separate application, its own container, its own pipeline. Copying the file there would put the mandatory Expires date in two independently deployed artifacts with nothing pinning them together. Give that six months and one of them is expired.

RFC 9116 §5.3 has consumers follow redirects, so that host answers a 301 to the canonical file. One file, one date, one place to renew it.

That server is otherwise deny-by-default on every path, including MCP’s own initialize and tools/list: it is internet-facing and it can write, so an anonymous caller has no business learning even its shape. But a security contact behind a credential is not a security contact, so this one route has to open. Make that hole exact:

private static final String PUBLIC_ROUTE = "GET /.well-known/security.txt";

static boolean isPublicRoute(String method, String path) {
    return PUBLIC_ROUTE.equals(method + " " + path);
}

Not startsWith("/.well-known"). A prefix would hand an unauthenticated caller every well-known route you might add later, for free; folding the method into the comparison keeps a POST to the same URI locked. The narrowness is the control — so a test enforces it rather than a comment asking nicely.

While you are in the headers

One adjacent thing, because it comes up in the same audits. X-XSS-Protection used to switch on the browser’s XSS Auditor, and for years every checklist told you to send 1; mode=block. Then the Auditor turned out to be an attack surface itself — it unilaterally stripped parts of a page, so a crafted string could disable legitimate scripts. Chrome removed it in 2019, Firefox never shipped it, and 0 — explicitly off — is now the correct value. Spring Security has defaulted to it since 5.8.

So a scanner flagging X-Xss-Protection: 0 is not reporting a bug in your site; it is reporting that its rule set encodes a recommendation that has since been inverted. An outdated rule is not a neutral rule — it costs triage time and trains people to click through warnings.

The short version

  • Publish the file. It takes ten minutes and it is the only standard answer to “who do I tell?”
  • Put the expiry in a test. An expired security.txt is required to be ignored, and it fails silently at 200.
  • Check what your host actually returns. An SPA fallback will serve HTML at 200 and look like a success.
  • Use a dedicated inbox — never the address your authorization checks compare against.
  • One file, many Canonical lines, or a 301 from the extra hosts. Never two copies with two expiry dates.

And verify it in a browser, not just with curl. A 200 is not proof that anything rendered — a page on this very site once shipped completely unstyled while its stylesheet returned a perfectly healthy 200 text/css, because a */ inside a comment had closed it early.

Mine is at zakaria.lu/.well-known/security.txt. If you find something, now you know where to send it.