Safeguard
Application Security•

security.txt on Next.js, Rails, WordPress and Static Sites

The file is the same everywhere, but each stack hides it in a different way. Where to put security.txt on Next.js, Rails, WordPress, Hugo, Jekyll and Eleventy, and how to prove it is live.

Safeguard Security Team
6 min read

On 18 October 2026 I fetched the live /.well-known/security.txt from three large web projects. WordPress.org carried Expires: 2027-06-30T15:00:00.000Z, the Rails site carried Expires: 2027-08-13T00:00:00.000Z, and Vercel carried Expires: 2027-09-28T12:00:00.000Z. All three returned 200 with a text/plain content type, and all three set an expiry less than a year out, which is what RFC 9116 recommends.

The file is easy to write. The hard part is getting your framework or host to serve a dot-prefixed folder as plain text. Here is how that goes on the stacks small teams actually run.

What's affected

Any site where a researcher might look for a reporting channel. RFC 9116 requires a Contact field and an Expires field, and says the file belongs at /.well-known/security.txt over HTTPS, with a top-level /security.txt allowed only for legacy reasons. It also tells researchers to be wary of redirects that point somewhere an attacker controls, so serve the file directly from your own hostname.

A good model is the WordPress.org file. It points to a reporting program, lists a policy URL and an acknowledgments page, and adds comments that route people with other problems elsewhere: plugin issues go to the plugins team, and hacked sites go to the site owner or host. That last part saves a small company a pile of misdirected mail.

Here is the file used in every example below.

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/security

How to check if you're exposed

Run this against production, not your repo. Framework and host behavior only shows up on the deployed URL.

URL=https://example.com/.well-known/security.txt
curl -sIL "$URL" | grep -i -E '^HTTP|^location|^content-type'
curl -sL "$URL" | grep -i -E '^(Contact|Expires):'

You want one final 200, a text/plain content type, at least one Contact line and an Expires date in the future. A 200 with text/html means your app is answering the unknown path with a page. A 404 usually means the build or deploy step dropped the dot folder.

What to do now

  1. Next.js. The Next.js docs say files in the public folder are served from the base URL, so public/.well-known/security.txt maps to /.well-known/security.txt. The docs also note the default header for these files is Cache-Control: public, max-age=0, so a renewal shows up right away. The Next.js docs do not mention dot folders, so test on your host after deploy.

  2. Rails. Put the file at public/.well-known/security.txt. The Rails configuration guide says config.public_file_server.enabled "Defaults to true" and should be set to false if NGINX or Apache serves static files instead. Check config/environments/production.rb, because older generated apps tied it to an environment variable. If nginx fronts your app, add a location block for the path, as in the example below.

location = /.well-known/security.txt {
    default_type text/plain;
    charset utf-8;
    alias /var/www/app/public/.well-known/security.txt;
}
  1. WordPress. Core has no setting for it. A request for security.txt support in core (Trac ticket 45964) was closed as "maybelater". Your options are a real file at /.well-known/security.txt in the web root, or a plugin such as Security.txt Manager, which serves both paths and requires rewrites to be enabled. On a subfolder multisite install the plugin only works for the main site. On managed hosting you may not be able to write to the web root, so use the plugin there.

  2. Hugo. Anything in the static directory is copied as is, so use static/.well-known/security.txt.

  3. Jekyll. Dotfiles and dot folders are excluded by default. The Jekyll docs describe include as forcing inclusion and use .htaccess as the example, so add this to _config.yml:

include:
  - .well-known
  1. Eleventy. Passthrough copy filters out dot files by default (copyOptions: { dot: false } in the docs), and the docs do not say how that applies to a .well-known directory. Add the passthrough, build, and look inside the output folder before you trust it. If it is missing, keep the file in a normal folder and let the host map the path.

  2. Netlify. A _headers file goes in the publish directory. To pin the content type and charset:

/.well-known/security.txt
  Content-Type: text/plain; charset=utf-8
  1. Set Expires about six months out and put the renewal on a team calendar.

How to avoid this trap

  1. Never auto-generate Expires as "today plus 180 days" on every build. A date that always moves forward hides the fact that nobody reads the mailbox.
  2. Put an external check on the live URL in CI or a scheduled job. Fail when fewer than 30 days remain.
  3. Point Contact at a shared mailbox, not a person.
  4. If you add a single page app, exclude /.well-known/ from the catch-all route so it cannot return your index page.

Why it matters

A missing or expired file tells a researcher that nobody is listening, and some will not bother to guess an address. Customers who ask for a vulnerability disclosure policy also check that the contact is reachable, and the file is the cheapest proof.

The file does not replace a policy. It is the machine-readable signpost, and the Policy line is where the promises live.

Quick check

Your Jekyll site builds fine but the live URL returns 404. What is the likely cause?

Jekyll excludes dot folders by default, so .well-known was not copied into the output. Add it under include in _config.yml.

Why is a 200 response with text/html a failure?

RFC 9116 expects text/plain. An HTML response usually means a catch-all route returned the app shell, so no researcher or tool can read the fields.

How far in the future should Expires be?

RFC 9116 recommends less than a year, to avoid staleness.

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.