Safeguard
Application Security•

security.txt That Works: Example, Hosting Rules and an Expiry Check

RFC 9116 needs two fields and one URL. Most broken security.txt files fail on Expires, content type or location. Here is a working file and a CI check.

Safeguard Security Team
4 min read

On 18 October 2026 GitHub's live security.txt carried Expires: 2026-11-17T09:05:33z, about 30 days out, while CISA's carried Expires: 2027-10-01T00:00:00.000Z. Both were served at /.well-known/security.txt as text/plain with a UTF-8 charset. Two very different companies, one pattern: the file exists, it parses, and the date is in the future.

That is the bar RFC 9116 sets, and plenty of small companies miss it. An expired or misplaced file is worse than none, because it tells a researcher that nobody is watching.

What's affected

Any organization that wants researchers to find a reporting channel without guessing. RFC 9116 is an Informational RFC, so it is guidance, not law. Its wording still matters because customers and scanners test against it. It defines eight fields. Two are mandatory:

  • Contact must be present and may repeat, listed in order of preference. Use a mailto: or tel: URI, or an https link to a form.
  • Expires must be present exactly once. The RFC recommends a value less than a year in the future.

The rest are optional: Acknowledgments, Canonical, Encryption, Hiring, Policy and Preferred-Languages. CISA's file also has a CSAF line, which is not one of the eight fields in the RFC, so treat that as an extension you do not need.

The file must be reachable at /.well-known/security.txt over https. A copy at /security.txt is allowed for legacy reasons, but if both exist the well-known path wins. The response must have Content-Type: text/plain with the charset set to utf-8.

How to check if you're exposed

curl -sIL https://example.com/.well-known/security.txt | grep -i -E '^HTTP|content-type'
curl -sL https://example.com/.well-known/security.txt

You want a final 200, a text/plain; charset=utf-8 header, a Contact: line and an Expires: line in the future. A 200 with text/html usually means a single page app is returning its index page for an unknown path.

What to do now

  1. Write the file. A complete working example:
# Report security issues here. Thank you for looking.
Contact: mailto:security@example.com
Contact: https://example.com/security/report
Expires: 2027-04-30T00:00:00z
Preferred-Languages: en
Canonical: https://example.com/.well-known/security.txt
Policy: https://example.com/vulnerability-disclosure
Acknowledgments: https://example.com/security/thanks
  1. Serve it with the right header. In nginx:
location = /.well-known/security.txt {
    default_type text/plain;
    charset utf-8;
    alias /var/www/security/security.txt;
}

On static hosts that skip dot directories at build time, add a rule or a copy step, then confirm with curl. Do not rely on a catch-all route.

  1. Set the date for six months out and put the renewal on a calendar owned by a team, not a person.
  2. Add the check below to CI so an expiring file fails a build before it embarrasses you.
  3. If you sign the file, RFC 9116 recommends an OpenPGP cleartext signature and says to include Canonical so the signature authenticates the location.
#!/usr/bin/env bash
url="${1:-https://example.com/.well-known/security.txt}"
exp=$(curl -sfL "$url" | grep -i '^Expires:' | head -n 1 | cut -d' ' -f2)
[ -z "$exp" ] && { echo "no Expires field"; exit 1; }
python3 - "$exp" <<'PY'
import sys, datetime
s = sys.argv[1].replace('Z', '+00:00').replace('z', '+00:00')
d = datetime.datetime.fromisoformat(s)
now = datetime.datetime.now(datetime.timezone.utc)
left = int((d.timestamp() - now.timestamp()) / 86400)
print(f"security.txt expires in {left} days")
sys.exit(0 if left > 14 else 1)
PY

How to avoid this trap

  1. Treat Expires as a renewal, not a constant. Generate the file in your build and set the date from the build date plus a fixed window,.
  2. Test the public URL, not the repo copy. Header and routing bugs only show up on the live path.
  3. Point Contact at a shared mailbox so staff changes cannot orphan it.
  4. Keep Canonical accurate if you serve the file from more than one hostname.

Why it matters

Under the EU CRA, Annex II asks manufacturers to give users a single point of contact for reporting vulnerabilities and where to find the disclosure policy. That obligation applies from 11 December 2027. A correct security.txt is a cheap way to publish that contact in a machine-readable form, though the regulation does not name the file.

Quick check

Which two security.txt fields are required by RFC 9116?

Contact and Expires. Everything else is optional.

Your file returns 200 but with text/html. What is probably wrong?

A catch-all route or single page app is answering the path. Add an explicit route that serves the file as text/plain with a utf-8 charset.

Sources

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.