security.txt: RFC 9116 rules, example and checker
A security.txt file tells researchers how to report a vulnerability, and RFC 9116 makes only two fields mandatory: Contact and Expires. We served five defective files and one corrected file from a local server, and a standard-library Python checker flagged each defect with the rule it breaks. The corrected file and two valid controls passed.
This is a scripted Python experiment with downloadable source and recorded output. No AI agent or model ran the test.
What security.txt is and what the fixture covers
A security.txt file is a small plain-text file that an organization publishes at /.well-known/security.txt. It lists contact methods and links for people who find a vulnerability. It is not a vulnerability disclosure policy. The optional Policy field links to the policy, and the policy says what testing is allowed and how reports are handled. We use RFC 9116 as the source for every rule below.
- Target: two short-lived HTTP servers on
127.0.0.1, started byverify.pyon free ports. Nothing external is contacted by the fixture. - Files: synthetic. Every address uses the reserved
example.comdomain. - Defects served: a missing
Expiresfield, an expiredExpiresdate, anhttp://contact, a wrong path and atext/htmlcontent type. - Not covered: TLS. The local servers use plain HTTP, so the checker runs with
--allow-insecure-local, which turns the transport error into a warning for loopback hosts only.
The experiment gives no permission to test another system. For authorized use see the Acceptable Use Policy. Section 5.5 of the RFC also says a security.txt file does not grant or deny permission to test the site that publishes it.
Run the checker and the fixture
Download check.py, fixture.py, verify.py and security.txt.example into one directory. You need Python 3.9 or later. The code uses only the standard library. Inspect the source, then run:
python3 verify.py --output rerun
The output directory must be new. The runner starts a broken server and a fixed server in turn, runs check.py against each case, saves the text and JSON output and stops the servers. check.py also works alone on a file (python3 check.py security.txt), on one URL, or on a whole site (python3 check.py --site https://example.com).
The recorded command was python3 verify.py --output recorded-run. It printed {"checks": 9, "passed": true, "failures": []}. That means all nine cases produced the expected result, including the five expected failures. The run began at 22:47:50 UTC on 10 November 2026 with Python 3.15.2 on macOS ARM64. The broken server listened on port 53877 and the fixed server on port 53894. Versions, timestamps and source hashes are in results.json.
Observed results for five broken files and a corrected one
Each row is one check.py run. A case passes when the set of ERROR rules equals the expected set and the exit code matches (1 for any error, 0 for none).
| Case | Defect or purpose | ERROR rule reported | Exit code |
|---|---|---|---|
| wrong-path | Valid file only at /security.txt, 404 at /.well-known/ | http-status | 1 |
| missing-expires | No Expires field | expires-required | 1 |
| expired-date | Expires: 2025-01-01T00:00:00Z | expires-stale | 1 |
| http-contact | Contact: http://example.com/security/report | https-only | 1 |
| html-content-type | Valid content served as text/html | content-type | 1 |
| corrected-file | Contact, Expires, Policy and Preferred-Languages | none | 0 |
| control-minimal-file | Only Contact and Expires | none | 0 |
| control-signed-structure | Clearsigned layout with a placeholder signature | none | 0 |
| copy-paste-file | The example in this post, with a pinned clock | none | 0 |
The controls catch a checker that rejects every file. It accepted a file with only the two required fields and a file in the signed layout. The signed control has a placeholder block, and check.py says it does not verify the signature. That check needs OpenPGP tooling this experiment does not use.
The RFC 9116 rules the checker enforces
These are the rules we verified against the RFC text. check.py reports an ERROR for a broken MUST and a WARN for a SHOULD.
- Contact (section 2.5.3): required, may repeat, and listed in order of preference. The value is a URI, so an email address needs
mailto:and a phone number needstel:. A web URI must begin withhttps://. - Expires (section 2.5.5): required and allowed once. The value is an RFC 3339 date-time such as
2027-06-30T23:59:59Z. The RFC recommends a date less than a year away. Section 5.3 says stale data should not be used and that having no file can be better than having a stale one. - Optional fields (sections 2.5.1 to 2.5.8):
Acknowledgments,Canonical,Encryption,Hiring,PolicyandPreferred-Languages. OnlyPreferred-Languagesis limited to one appearance.Encryptionholds a URI to a key, never the key itself. Unknown fields must be ignored. - Location and transport (sections 3 and 5.7): the file goes under
/.well-known/, is fetched over HTTPS, and has aContent-Typeoftext/plainwithutf-8as the charset. A top-level/security.txtis allowed for legacy compatibility, and the/.well-known/copy wins if both exist. - Format (sections 2 and 4): UTF-8 text. A line is blank, a
#comment, orName: valuewith one space after the colon. Field names are case-insensitive. Lines end in LF or CRLF. - Signing (section 2.3): recommended, as an OpenPGP cleartext signature, together with a
Canonicalfield. If aCanonicalfield exists and the fetch URL is not listed, the file should not be trusted.
Verify a finding before you report it
Before you tell an organization its security.txt is broken, repeat the request yourself and keep the evidence. The recorded expired-date case shows the pattern. The checker output for that case contains this line:
ERROR expires-stale RFC 9116 s2.5.5, 5.3 Expires 2025-01-01T00:00:00Z is in the past. The data is stale and should not be used.
Confirm it with a raw request so you see the same bytes the checker saw: curl -i https://example.com/.well-known/security.txt. Record the status line, the Content-Type header, the date you ran it and the exact field value. A stale Expires is a hygiene issue. It means reports might not reach a person, and it is not a vulnerability in the application. Say so in the report. Also check that the contact still works, because a valid file can point at a mailbox nobody reads, and this checker does not test that.
The wrong-path case needs one more step. A 404 at /.well-known/security.txt could mean the site publishes the file somewhere else, such as a redirect or a legacy top-level path. Request both locations and record both results before you call the file missing.
A valid security.txt example to copy
You do not need a generator. Copy this security.txt example, replace every example.com value with your own, set an Expires date less than a year ahead, and run check.py on it. The recorded run passed this exact file (security.txt.example) with the clock pinned to 10 November 2026, so a rerun still passes after the date below has passed.
# security.txt for example.com (RFC 9116). Replace every example.com value.
# Reports: security@example.com is the preferred contact.
Contact: mailto:security@example.com
Contact: https://example.com/security/report
Expires: 2027-06-30T23:59:59Z
Encryption: https://example.com/.well-known/pgp-key.txt
Policy: https://example.com/security/policy
Acknowledgments: https://example.com/security/thanks
Preferred-Languages: en
Canonical: https://example.com/.well-known/security.txt
Serve it at https://your-domain/.well-known/security.txt with Content-Type: text/plain; charset=utf-8. Put the URL you serve it from in the Canonical field. Link your vulnerability disclosure policy from Policy. Add the Expires date to a calendar. An expired file is an ERROR in check.py, and the RFC treats it as stale.
The fixed server builds its corrected file with this code from fixture.py. The Expires value is 180 days after the server starts, so the file stays valid when you rerun it:
def corrected():
return ("# Synthetic security.txt for the local fixture\r\n"
"Contact: mailto:security@example.com\r\n"
"Contact: https://example.com/security/report\r\n"
"Expires: " + future(180) + "\r\n"
"Policy: https://example.com/security/policy\r\n"
"Preferred-Languages: en\r\n")
Each defect has a direct repair. Add the missing Expires line or move the date forward. Change http:// to https:// in the contact. Publish at /.well-known/security.txt and serve it as text/plain. After the repair we ran the same checker against the fixed server and it reported zero errors. That retest shows the checker accepts the file under the RFC's rules. It does not show that a researcher's message reaches anyone.
What excessionlabs.ai returned when we checked
We also requested our own file. On 10 November 2026 at 22:47:36 UTC, curl -sS -i https://excessionlabs.ai/.well-known/security.txt returned HTTP/2 404 with content-type: text/html. The body was the site's HTML not-found page. A request for https://excessionlabs.ai/security.txt also returned 404. python3 check.py --site https://excessionlabs.ai reported one error, http-status, and exited with code 1.
So at that time the excessionlabs.ai domain published no security.txt file. After this check, the lab published a file at that path, and check.py reports no ERROR finding for it. The recorded responses are in live-wellknown.http, live-root.http and live-check.txt. This request ran outside verify.py because the fixture must not contact external hosts.
Inspect the evidence bundle
- Machine-readable results: nine cases with expected and observed ERROR rules, ports, exit codes and output hashes.
- Recorded commands: the exact
check.pyarguments. The ports belonged to the finished run. A separate live-check script lists the three commands for the excessionlabs.ai request. - Per-case output: for example the expired-date text and its JSON.
- SHA-256 manifest and README. The manifest is an integrity check, not independent attestation.
All file contents are synthetic. The only non-local data is the 404 response from our own site.
What this experiment does not cover
The checker covers the rules listed above on nine cases. It does not verify an OpenPGP signature, validate a TLS certificate, check that mailboxes or links respond, resolve dns: or openpgp4fpr: values, or follow every redirect rule. Its URI check is a pattern match, not a full RFC 3986 parser. Its date check accepts the RFC 3339 form with a T and rejects a space separator. Passing check.py shows a file matches the rules we implemented. It is not an audit. The fixture is a toy. A site whose router answers 200 with an HTML page at the well-known path would fail the content-type rule, and we did not test that case.
The file is one input to a disclosure process. For an API-focused test that starts from written scope, see authorization testing for APIs, and for a wider list of API risks see the OWASP API Top 10 walkthrough.
Excession Labs runs this kind of external check in its security assessments. See how findings look in a sample report.