Safeguard
Compliance•

security.txt in 2026: The Ten-Minute File Behind the CRA's Vulnerability Contact

What RFC 9116 requires, what the EU Cyber Resilience Act actually says about a vulnerability contact, a security.txt you can copy, and a short checker you can run in CI so the file never goes stale.

Safeguard Security Team
10 min read

If a researcher finds a vulnerability in your product tonight, can they work out where to send it in under a minute? For most teams, that is what a security.txt file answers. It is a short text file at /.well-known/security.txt, standardised in RFC 9116, that tells people where to report security issues and how you want them handled.

It has also become more than courtesy. The EU Cyber Resilience Act requires manufacturers to designate a single point of contact for vulnerability reports and make it easy to find. This guide is for engineering and security leads who own that contact. It covers what the RFC requires, what the CRA actually says, a file you can copy, and a checker you can run in CI so the file does not quietly go stale.

The short answer

A security.txt file is a plain-text file served over HTTPS at https://<your-domain>/.well-known/security.txt that lists how to report vulnerabilities to you. RFC 9116 makes exactly two fields mandatory: Contact (an email or HTTPS URL, and you may list several) and Expires (a date, appearing once, which the RFC recommends be less than a year away). Everything else is optional, though Policy, Canonical and a PGP signature are worth adding. The CRA does not mention security.txt by name, but the file is the simplest machine-readable way to publish the vulnerability contact the regulation asks for.

What the CRA actually requires

It is easy to overstate this, so here is the text. Regulation (EU) 2024/2847 has three provisions that matter for a vulnerability contact.

  • Article 13(17): manufacturers "shall designate a single point of contact" so users can communicate with them directly and rapidly, "including in order to facilitate reporting on vulnerabilities". The contact must be easily identifiable.
  • Annex I, Part II, points 5 and 6: manufacturers must "put in place and enforce a policy on coordinated vulnerability disclosure", and must make it easier to share information about potential vulnerabilities, "including by providing a contact address for the reporting of the vulnerabilities". Point 6 explicitly covers vulnerabilities in third-party components too.
  • Annex II, point 2: the information shipped with the product must include that single point of contact and say where the coordinated vulnerability disclosure policy can be found.

On timing, Article 71 says the regulation as a whole applies from 11 December 2027. The exception is Article 14, the duty to report actively exploited vulnerabilities and severe incidents, which has applied since 11 September 2026. So the contact and policy obligations bite in late 2027. A report arriving through that contact today, though, can be how you first learn that a vulnerability is being exploited, and that is what starts the Article 14 clock. We covered those deadlines in CRA Article 14 reporting.

The CRA does not prescribe a format. A security.txt file will not satisfy these provisions on its own, because you still need the policy and the people behind the inbox. What it does well is make the contact easy to identify, for humans and for the scanners researchers use.

The RFC 9116 rules that matter

RFC 9116 was published in April 2022. The requirements that trip people up are few:

RuleWhat the RFC says
Location/.well-known/security.txt, over https. A legacy /security.txt is allowed, but the .well-known copy wins if both exist
Content typetext/plain with charset=utf-8
ContactRequired. May repeat. Web URIs must start with https://
ExpiresRequired, exactly once. RFC 3339 date. Recommended less than a year ahead
EncryptionA link to your key, never the key pasted inline
Preferred-LanguagesOptional, at most once
SignatureOpenPGP cleartext signature recommended. If you sign, add Canonical too
ScopeThe file applies only to the domain or IP it is served from
Unknown fieldsResearchers must ignore them

The IANA registry has grown beyond the RFC. As of October 2026 it also lists CSAF, a link to a CSAF 2.0 provider-metadata.json for machine-readable advisories, and Bug-Bounty, which takes True or False to say whether you pay for reports. Anything not in the registry, say a custom Security-Team: line, will simply be ignored.

A file you can copy

Contact: mailto:security@example.com
Contact: https://example.com/security/report
Expires: 2027-09-30T23:59:59Z
Encryption: https://example.com/.well-known/pgp-key.txt
Preferred-Languages: en, de
Canonical: https://example.com/.well-known/security.txt
Policy: https://example.com/security/disclosure-policy
Acknowledgments: https://example.com/security/thanks
Bug-Bounty: False

# Please include affected product and version, reproduction steps and impact.

A few choices in there are deliberate:

  • Two contacts. An alias like security@ survives staff changes. A web form lets you collect structured reports and gives reporters who don't use email a route in.
  • Bug-Bounty: False. If you don't pay, say so up front. It prevents the awkward exchange where a researcher assumed you did.
  • The Policy link. This is where the CRA's "policy on coordinated vulnerability disclosure" lives. If you don't have one yet, start with how to write a vulnerability disclosure policy developers respect.

For a live reference, ours is at safeguard.sh/.well-known/security.txt.

To sign it with an existing OpenPGP key, write the unsigned file first and then:

gpg --clearsign --local-user security@example.com --output security.txt security.txt.unsigned

Publish the signed output, and put the public key at the Encryption URL so researchers can verify it.

Check yours, and keep checking

The common failure is not a missing file. It is a file whose Expires date passed months ago, which tells researchers to distrust it. This script checks a live domain against the rules above using only the Python standard library:

#!/usr/bin/env python3
"""Check a domain's security.txt against the main RFC 9116 rules.
Usage: python3 check_security_txt.py example.com   (Python 3.9+)"""
import sys, urllib.request
from datetime import datetime, timezone, timedelta

KNOWN = {"acknowledgments", "canonical", "contact", "csaf", "encryption", "expires",
         "hiring", "policy", "preferred-languages", "bug-bounty"}  # IANA registry
problems, warnings = [], []

domain = sys.argv[1]
url = f"https://{domain}/.well-known/security.txt"
req = urllib.request.Request(url, headers={"User-Agent": "security-txt-check/1.0"})
with urllib.request.urlopen(req, timeout=15) as resp:
    final_url = resp.geturl()
    ctype = resp.headers.get("Content-Type", "")
    body = resp.read().decode("utf-8", errors="replace")

if final_url != url:
    warnings.append(f"redirected to {final_url} (allowed, but check it stays on your domain)")
if not final_url.startswith("https://"):
    problems.append("final URL is not https")
if not ctype.lower().startswith("text/plain"):
    problems.append(f"Content-Type is {ctype!r}, must be text/plain")
elif "charset=utf-8" not in ctype.lower().replace(" ", ""):
    warnings.append(f"Content-Type {ctype!r} does not state charset=utf-8")

signed = body.lstrip().startswith("-----BEGIN PGP SIGNED MESSAGE-----")
text = body
if signed:  # keep only the signed text: after the armor headers, before the signature
    text = body.split("\n\n", 1)[1].split("-----BEGIN PGP SIGNATURE-----")[0]
fields = {}
for line in text.splitlines():
    line = line.strip()
    if not line or line.startswith("#") or ":" not in line:
        continue
    line = line.removeprefix("- ")  # dash-escaping inside signed text
    name, value = (part.strip() for part in line.split(":", 1))
    fields.setdefault(name.lower(), []).append(value)

if not fields.get("contact"):
    problems.append("no Contact field (required)")

exp = fields.get("expires", [])
if len(exp) != 1:
    problems.append(f"Expires must appear exactly once (found {len(exp)})")
else:
    try:
        when = datetime.fromisoformat(exp[0].replace("Z", "+00:00").replace("z", "+00:00"))
        now = datetime.now(timezone.utc)
        if when <= now:
            problems.append(f"Expires {exp[0]} is in the past: the file is stale")
        elif when - now > timedelta(days=365):
            warnings.append(f"Expires {exp[0]} is more than a year out (RFC recommends less)")
        elif when - now < timedelta(days=30):
            warnings.append(f"Expires {exp[0]} is within 30 days: renew it")
    except ValueError:
        problems.append(f"Expires is not an RFC 3339 date: {exp[0]}")

if len(fields.get("preferred-languages", [])) > 1:
    problems.append("Preferred-Languages must not appear more than once")
canon = fields.get("canonical", [])
if canon and url not in canon and final_url not in canon:
    warnings.append(f"Canonical {canon} does not list the URL this file was served from")
for name, values in fields.items():
    if name not in KNOWN:
        warnings.append(f"unregistered field {name!r} (researchers will ignore it)")
    for v in values:
        if v.startswith("http://"):
            problems.append(f"{name} URI must be https: {v}")
if not signed:
    warnings.append("not OpenPGP-signed (RECOMMENDED, not required)")

print(f"{domain}: {len(problems)} problem(s), {len(warnings)} warning(s)")
for p in problems:
    print("  FAIL", p)
for w in warnings:
    print("  warn", w)
sys.exit(1 if problems else 0)

Against the reference site for the standard, it passes with one advisory note:

$ python3 check_security_txt.py securitytxt.org
securitytxt.org: 0 problem(s), 1 warning(s)
  warn not OpenPGP-signed (RECOMMENDED, not required)

Against a deliberately broken test file it fails, with a non-zero exit code a CI job can act on:

example.com: 3 problem(s), 2 warning(s)
  FAIL Expires 2025-01-01T00:00:00Z is in the past: the file is stale
  FAIL Preferred-Languages must not appear more than once
  FAIL contact URI must be https: http://example.com/report
  warn unregistered field 'security-team' (researchers will ignore it)
  warn not OpenPGP-signed (RECOMMENDED, not required)

We ran it across a handful of well-known domains while writing this. Every file passed the hard rules. The warnings that came up most were missing signatures, an Expires more than a year out, and one served through a redirect to a path outside .well-known. All are legal, all are worth knowing about.

To make sure the file never expires unnoticed, run the check on a schedule. A minimal GitHub Actions workflow, assuming the script is committed at scripts/check_security_txt.py:

name: security-txt
on:
  schedule:
    - cron: "17 6 * * 1"   # Mondays 06:17 UTC
  workflow_dispatch:
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: python3 scripts/check_security_txt.py example.com

The 30-day warning gives you a month's notice. Bump Expires every time you edit the file, and set a calendar reminder as a backstop.

Mistakes we see most often

The inbox nobody reads. A security@ alias that forwards to one person who left last year is worse than no file, because it looks like a working channel. Route it to a monitored queue and test it once a quarter by sending yourself a report.

One file for many domains. RFC 9116 scopes the file to the domain that serves it. If your product runs on app.example.com and api.example.com, each should serve a file, or redirect to the main one.

Contact without policy. Researchers want to know whether testing your production systems is allowed, how quickly you'll respond and whether you'll credit them. That belongs in the policy page the Policy field links to, and it is the half of the CRA requirement a contact address cannot meet.

Treating it as a compliance checkbox. The file only helps if a report that arrives through it reaches someone who can triage it the same day. Under Article 14, an early warning about an actively exploited vulnerability is due within 24 hours of the manufacturer becoming aware of it, so a report that waits in a shared inbox over a weekend costs you most of that window.

Is this worth doing if the CRA doesn't apply to you?

Probably yes, though for a different reason. If you don't sell products with digital elements into the EU, nothing here is legally required. The practical argument is that researchers and automated scanners already look for this file first. Without it, reports go to sales@, to a support ticket, or to a public social post. Ten minutes of setup moves those conversations into a channel you control.

The honest limit: a security.txt file is a pointer. It doesn't triage, fix or disclose anything. Teams that publish one and stop there have improved how findable they are, not how they respond. Make the file the visible end of a process that already works, and the CRA's single point of contact mostly takes care of itself.

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.