Someone finds an authorization bug in your API on a Friday evening. There is no address on the site, so they try the sales form, a founder’s social account, and then a public post. The report that could have arrived in your inbox arrives in your mentions instead, and the clock on a fix starts with an audience watching.
What you get
You will end up with a file at a fixed, predictable path that names where to send a report and when the file stops being trustworthy. You also get a check that fails before the date passes. This is for you if you run an API with no published disclosure route.
Short answer
Serve a plain text file at /.well-known/security.txt over HTTPS with Contact and Expires set.
Contact takes a URI, so write mailto: or https: rather than a bare address, and Expires is
one timestamp that has not passed. Add a scheduled check that fails a month before that date,
because a lapsed file tells a scanner the whole policy is abandoned.
You will need
Control of the public domain and its /.well-known path, and Node 22 or later to run the checker.
The format is RFC 9116,1 and it is deliberately small:
field name, colon, value, one per line. The /.well-known prefix it uses is registered with
IANA, alongside the
catalog entry an API may also serve.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A hand-written file with a scheduled check | Any team that can serve a static file and run a scheduled job | An expiry you own, so a forgotten renewal turns the file into a liability | Nobody is on the other end of the address you would publish |
| A hosted disclosure program | Reports arrive faster than one engineer can triage them | A subscription, and a third party standing between you and the reporter | The volume is a handful a year and triage is not the bottleneck |
| The securitytxt.org generator | A first file, written by someone who has not read the RFC yet | A form that produces one file once, with nothing watching it afterwards | You want the file generated and validated in the same pipeline that deploys it |
| No file at all | Nothing, once the API is public and has users | Reports that route through social media, or never arrive at all | Always, once anyone outside the company can reach the API |
The choice is between owning triage and buying it. A file plus an inbox costs nothing and puts
every report in front of an engineer, which is right while the volume is small. A hosted program
takes the noise, sets expectations about scope and rewards, and inserts a third party into the
conversation with the person who found the bug. Either way the file is the same file, and only the
Contact value changes.
Write the file
Two fields are required and the rest are optional. Machine parsers read the required pair and humans read the rest.
Contact: mailto:security@example.com
Contact: https://example.com/security/report
Expires: 2027-03-01T00:00:00.000Z
Encryption: https://example.com/.well-known/security-key.asc
Policy: https://example.com/security/policy
Acknowledgments: https://example.com/security/thanks
Preferred-Languages: en, ga
Canonical: https://example.com/.well-known/security.txt
Contact may repeat, in preference order.2 Expires may not, and a second one makes the file
ambiguous rather than generous. Canonical names where the file is meant to live, which is what
lets a reader tell a copy served from a mirror or a proxy from the real thing.
Serve it as text/plain; charset=utf-8. A server that guesses application/octet-stream makes
browsers download the file instead of showing it, and some scanners skip it. The signed .asc variant is optional, and it only means
anything if the key is published somewhere a reporter can already verify. A signature over a file
whose key lives beside it proves nothing that the transport did not already prove.
Check the expiry in CI
The field that fails is Expires, because it is the only one with a deadline attached.
const expires = (fields.get('expires') ?? [])[0]
if (!expires) problems.push('no Expires field')
else {
const at = Date.parse(expires)
if (Number.isNaN(at)) problems.push(`Expires is not a timestamp: ${expires}`)
else {
const days = Math.floor((at - now) / 86_400_000)
if (days < 0) problems.push(`Expires passed ${-days} days ago`)
else if (days < renewDays) problems.push(`Expires in ${days} days, inside the ${renewDays} day renewal window`)
}
}
Pass the reference time in rather than reading the clock inside the function. That makes the rule testable, and it lets the same code answer two questions. Does the file pass today, and would it pass on the day the next release ships.
Run it in two places. A scheduled job gives you warning while there is time to act. A check in the deploy pipeline stops a lapsed file from being republished by a deploy that had nothing to do with security. The second one matters more than it sounds: a static site rebuilt weekly will happily redeploy a file that expired months ago.
Check it worked
Run the checker over the served file and over one that is about to lapse.
node demo.mjs
security.txt:
ok
stale.txt:
FAIL Contact is not a URI: security@example.com
FAIL Expires in 6 days, inside the 30 day renewal window
The second file is the shape most real ones take after a year. Because the address still works and both defects pass a human reading, it takes a parser to find them. One is a bare mail address that a parser cannot treat as a contact URI. The other is an expiry close enough that the next scan reports the policy as dead.
node --test check.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 113.598725
When it goes wrong
Scanners report no file although it is deployed. The path is wrong. It is
/.well-known/security.txt, not /security.txt, and a framework that rewrites unknown paths to an
HTML error page returns 200 with markup, which reads as a malformed file rather than a missing one.
The file downloads instead of rendering. The media type is wrong. Set text/plain explicitly for
that path, because extension-based guessing does not always reach a dotted directory.
Reports arrive at an address nobody watches. A shared inbox with no rotation is the usual cause.
Point Contact at a route with an owner, and test it by sending a report yourself once a quarter.
The file is right, but the reports still go elsewhere. A researcher looks at the domain they were
testing, so a file on the marketing site does nothing for an API served from its own host name.
Serve one per host that answers requests, and let Canonical list all of them.
When not to do this
Do not publish an address you cannot answer within days. A stated contact route sets an expectation, and a reporter who gets silence for two weeks will publish rather than wait. If nobody owns the inbox, fix that before you publish the file.
Do not put a person’s name in it. People change teams, and a file that names an individual outlives their tenure and routes reports to somebody who cannot act. Use a role address.
Do not set Expires years out to avoid maintenance.3 The field exists to prove the file is still
watched, and a five-year expiry carries no information at all. Pick a year, and put the renewal in
the same calendar as your certificate renewals.
Related how-tos
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
Footnotes
-
The RFC began on September 10, 2017, as draft-foudil-securitytxt-00, titled “A Method for Web Security Policies” and submitted by one author with no working group behind it. Twelve revisions later, and with a second author, the IETF published it in April 2022 as RFC 9116, in the Informational category. Four years and seven months is the going rate for a file with two required fields. ↩︎ Back to text
-
Contactis ordered andPreferred-Languagesis not. Section 2.5.8 of RFC 9116 says the order in which the languages appear is not an indication of priority, and that the listed languages are intended to have equal priority. A field named for preference in which nothing is preferred, soen, gaandga, ensay the same thing. ↩︎ Back to text -
Section 2.5.5 of RFC 9116 recommends a value less than a year into the future, to avoid staleness. Its own example reads
Expires: 2021-12-31T18:37:07z, and the RFC was published in April 2022, so the example had lapsed before the document that carries it appeared. The file format designed to expire on time arrived with an expired example in it. ↩︎ Back to text