# How to publish security.txt for an API

> Serve an RFC 9116 security.txt so a researcher who finds a bug in your API knows where to send it, and add the expiry check that stops the file going stale.

Source: https://voxgig.com/howto/publish-security-txt-for-an-api

- Audience: api-producer
- Level: beginner
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## 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](https://www.rfc-editor.org/rfc/rfc9116.html),[^1] and it is deliberately small:
field name, colon, value, one per line. The `/.well-known` prefix it uses is registered with
[IANA](https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml), alongside the
[catalog entry](https://www.rfc-editor.org/rfc/rfc9727.html) 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](https://www.rfc-editor.org/rfc/rfc9116.html#section-2.5) | 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](https://docs.hackerone.com/en/articles/8368965-vdp-vs-bbp) | 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](https://securitytxt.org/) | 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](https://securitytxt.org/#what-if-i-dont-have-a-security-txt) | 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.

```text title="security.txt"
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.

```js title="check.mjs"
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.

```bash
node demo.mjs
```

```text output
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.

```bash
node --test check.test.mjs
```

```text output
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

- [Keep test and live API keys from crossing environments](/howto/keep-test-and-live-keys-apart)

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

## Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command
printed.

[^1]: The RFC began on September 10, 2017, as
[draft-foudil-securitytxt-00](https://datatracker.ietf.org/doc/html/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](https://datatracker.ietf.org/doc/rfc9116/), in the Informational category. Four
years and seven months is the going rate for a file with two required fields.

[^2]: `Contact` is ordered and `Preferred-Languages` is not.
[Section 2.5.8](https://www.rfc-editor.org/rfc/rfc9116.html#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, so `en, ga` and `ga, en` say the same thing.

[^3]: [Section 2.5.5](https://www.rfc-editor.org/rfc/rfc9116.html#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.