security.txt
The security.txt feature generates a /.well-known/security.txt file in your build output during astro build. It is configured through the securityTxt option on the integration and follows the RFC 9116 security disclosure standard.
If securityTxt is omitted from your integration config, a warning is logged recommending you add it. If securityTxt: false is set, no file is generated and no warning is logged.
Options
Section titled “Options”SecurityTxtOptions
Section titled “SecurityTxtOptions”| option | type | default | required | description |
|---|---|---|---|---|
contact | string | string[] | - | Yes | Contact address(es). Each must be a mailto: address or an https:// URL. |
expires | Date | string | SecurityTxtExpiresDuration | - | Yes | Expiry for the disclosure policy. Accepts a Date, ISO 8601 string, or duration like "1 year". |
encryption | string | string[] | - | No | HTTPS URL(s) to a PGP key or encryption information. |
acknowledgments | string | string[] | - | No | HTTPS URL(s) to a page acknowledging security reporters. |
preferredLanguages | string | string[] | - | No | BCP 47 language tag(s) for the preferred language(s) of security reports. |
canonical | string | string[] | - | No | HTTPS URL(s) to the canonical location of this security.txt file. |
policy | string | string[] | - | No | HTTPS URL(s) to the security disclosure policy. |
hiring | string | string[] | - | No | HTTPS URL(s) to security-related job listings. |
csaf | string | string[] | - | No | HTTPS URL(s) to a CSAF provider metadata file. |
SecurityTxtExpiresDuration
Section titled “SecurityTxtExpiresDuration”A human-readable relative duration string in the form "N unit", where unit is one of: day, days, month, months, year, years.
Examples: "30 days", "6 months", "1 year".
Usage & Examples
Section titled “Usage & Examples”Basic usage
Section titled “Basic usage”eminence({ securityTxt: { contact: "mailto:security@example.com", expires: "1 year", },});Output at /.well-known/security.txt:
Contact: mailto:security@example.comExpires: 2027-05-05T00:00:00.000ZWith multiple contacts and policy
Section titled “With multiple contacts and policy”eminence({ securityTxt: { contact: ["mailto:security@example.com", "https://example.com/security"], expires: "6 months", policy: "https://example.com/security-policy", preferredLanguages: ["en", "fr"], },});Output:
Contact: mailto:security@example.comContact: https://example.com/securityExpires: 2026-11-05T00:00:00.000ZPreferred-Languages: en, frPolicy: https://example.com/security-policyWith explicit Date expiry
Section titled “With explicit Date expiry”eminence({ securityTxt: { contact: "mailto:security@example.com", expires: new Date("2027-01-01T00:00:00Z"), },});Output:
Contact: mailto:security@example.comExpires: 2027-01-01T00:00:00.000ZComplete
Section titled “Complete”All options provided explicitly.
Input:
eminence({ securityTxt: { contact: ["mailto:security@example.com", "https://example.com/security"], expires: "1 year", encryption: "https://example.com/pgp-key.asc", acknowledgments: "https://example.com/thanks", preferredLanguages: ["en", "fr"], canonical: "https://example.com/.well-known/security.txt", policy: "https://example.com/security-policy", hiring: "https://example.com/security-jobs", csaf: "https://example.com/.well-known/csaf/provider-metadata.json", },});Output at /.well-known/security.txt:
Contact: mailto:security@example.comContact: https://example.com/securityExpires: 2027-05-05T00:00:00.000ZEncryption: https://example.com/pgp-key.ascAcknowledgments: https://example.com/thanksPreferred-Languages: en, frCanonical: https://example.com/.well-known/security.txtPolicy: https://example.com/security-policyHiring: https://example.com/security-jobsCSAF: https://example.com/.well-known/csaf/provider-metadata.jsonExplicit opt-out
Section titled “Explicit opt-out”eminence({ securityTxt: false,});No file is generated and no warning is logged.
Decisions Made
Section titled “Decisions Made”contact requires a mailto: address or https:// URL
Section titled “contact requires a mailto: address or https:// URL”Raw strings without a scheme or with http:// are rejected at build time. This enforces the RFC 9116 requirement that contact values be actionable and verifiable.
All URL fields require HTTPS
Section titled “All URL fields require HTTPS”encryption, acknowledgments, canonical, policy, hiring, and csaf only accept https:// URLs. HTTP URLs are rejected with an error. This matches the RFC 9116 requirement that these fields use secure URLs.
expires accepts three formats
Section titled “expires accepts three formats”You can pass a JavaScript Date object, an ISO 8601 date string, or a human-readable duration string like "30 days". The duration is resolved relative to the time of the build. All formats are normalized to an ISO 8601 string in the output.
preferredLanguages is joined as a comma-separated list
Section titled “preferredLanguages is joined as a comma-separated list”Multiple language tags are combined into a single Preferred-Languages line, matching the RFC 9116 format rather than emitting one line per tag.
Existing security.txt is never overwritten
Section titled “Existing security.txt is never overwritten”If a /.well-known/security.txt file already exists in the build output when astro build runs, generation is skipped and a warning is logged. The securityTxt option is also set to false internally to suppress any repeated attempt within the same build.
Setting false is the explicit opt-out
Section titled “Setting false is the explicit opt-out”Passing securityTxt: false disables the feature completely with no output. Omitting securityTxt logs a recommendation warning so new projects are nudged to make an intentional choice.
Source code
Section titled “Source code”import { constants } from "node:fs";import { access, mkdir, writeFile } from "node:fs/promises";import { dirname, join } from "node:path";import { fileURLToPath } from "node:url";import type { IntegrationRuntimeContext } from "..";
export type SecurityTxtExpiresUnit = | "day" | "days" | "month" | "months" | "year" | "years";
export type SecurityTxtExpiresDuration = `${number} ${SecurityTxtExpiresUnit}`;
export type SecurityTxtOptions = { contact: string | string[]; expires: Date | string | SecurityTxtExpiresDuration; encryption?: string | string[]; acknowledgments?: string | string[]; preferredLanguages?: string | string[]; canonical?: string | string[]; policy?: string | string[]; hiring?: string | string[]; csaf?: string | string[];};
export const SECURITY_TXT_RECOMMENDATION = "Recommendation: follow eminence-astro-suite.xeffen25.com/recommendations/why-you-should-add-a-security-txt to learn why adding a basic security.txt is important.";
export const SECURITY_TXT_RELATIVE_PATH = "/.well-known/security.txt";
const toArray = <T>(value: T | T[] | undefined): T[] => { if (value === undefined) { return []; }
return Array.isArray(value) ? value : [value];};
const assertHttpsUrl = (value: string, fieldName: string): string => { let parsed: URL;
try { parsed = new URL(value); } catch { throw new Error( `Invalid ${fieldName} value "${value}": expected a valid absolute URL.`, ); }
if (parsed.protocol !== "https:") { throw new Error( `Invalid ${fieldName} value "${value}": only https:// URLs are allowed.`, ); }
return value;};
const assertContact = (value: string): string => { if (value.startsWith("mailto:")) { return value; }
return assertHttpsUrl(value, "Contact");};
const EXPIRES_DURATION_PATTERN = /^(\d+)\s+(day|days|month|months|year|years)$/i;
const addDuration = ( now: Date, amount: number, unit: SecurityTxtExpiresUnit,): Date => { const result = new Date(now.getTime());
switch (unit) { case "day": case "days": result.setUTCDate(result.getUTCDate() + amount); return result; case "month": case "months": result.setUTCMonth(result.getUTCMonth() + amount); return result; case "year": case "years": result.setUTCFullYear(result.getUTCFullYear() + amount); return result; }};
const parseExpiresDuration = ( value: string,): { amount: number; unit: SecurityTxtExpiresUnit } | undefined => { const match = value.match(EXPIRES_DURATION_PATTERN); if (!match) { return undefined; }
const amount = Number.parseInt(match[1], 10); if (!Number.isSafeInteger(amount) || amount < 1) { throw new Error( `Invalid Expires value "${value}": duration amount must be a positive integer.`, ); }
return { amount, unit: match[2].toLowerCase() as SecurityTxtExpiresUnit, };};
const normalizeExpires = ( value: SecurityTxtOptions["expires"], now: Date = new Date(),): string => { if (value instanceof Date) { if (Number.isNaN(value.getTime())) { throw new Error( "Invalid Expires value: received an invalid Date instance.", ); }
return value.toISOString(); }
const duration = parseExpiresDuration(value); if (duration) { return addDuration(now, duration.amount, duration.unit).toISOString(); }
const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { throw new Error( `Invalid Expires value "${value}": expected an ISO 8601 date string, Date object, or a duration like "30 days", "6 months", or "1 year".`, ); }
return parsed.toISOString();};
const buildSecurityTxt = ( options: SecurityTxtOptions, now: Date = new Date(),): string => { const lines: string[] = [];
for (const value of toArray(options.contact)) { lines.push(`Contact: ${assertContact(value)}`); }
if (lines.length === 0) { throw new Error("Missing required securityTxt.contact value."); }
lines.push(`Expires: ${normalizeExpires(options.expires, now)}`);
for (const value of toArray(options.encryption)) { lines.push(`Encryption: ${assertHttpsUrl(value, "Encryption")}`); }
for (const value of toArray(options.acknowledgments)) { lines.push(`Acknowledgments: ${assertHttpsUrl(value, "Acknowledgments")}`); }
const preferredLanguages = toArray(options.preferredLanguages).join(", "); if (preferredLanguages.length > 0) { lines.push(`Preferred-Languages: ${preferredLanguages}`); }
for (const value of toArray(options.canonical)) { lines.push(`Canonical: ${assertHttpsUrl(value, "Canonical")}`); }
for (const value of toArray(options.policy)) { lines.push(`Policy: ${assertHttpsUrl(value, "Policy")}`); }
for (const value of toArray(options.hiring)) { lines.push(`Hiring: ${assertHttpsUrl(value, "Hiring")}`); }
for (const value of toArray(options.csaf)) { lines.push(`CSAF: ${assertHttpsUrl(value, "CSAF")}`); }
return `${lines.join("\n")}\n`;};
const exists = async (path: string): Promise<boolean> => { try { await access(path, constants.F_OK); return true; } catch (error) { if ( error && typeof error === "object" && "code" in error && error.code === "ENOENT" ) { return false; }
throw error; }};
export async function generateSecurityTxt({ dir, options, logger,}: IntegrationRuntimeContext): Promise<void> { const input = options.securityTxt;
if (input === false) { return; }
if (input === undefined) { logger.warn( `No security.txt file was generated because securityTxt is undefined. ${SECURITY_TXT_RECOMMENDATION}`, ); return; }
const outputPath = join(fileURLToPath(dir), ".well-known", "security.txt");
if (await exists(outputPath)) { logger.warn( `Could not generate "${SECURITY_TXT_RELATIVE_PATH}" because it already exists. Disabling securityTxt generation for this build.`, ); options.securityTxt = false; return; }
try { const content = buildSecurityTxt(input); await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, content, "utf-8"); logger.info(`Generated "${SECURITY_TXT_RELATIVE_PATH}"`); } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.error( `Failed to generate "${SECURITY_TXT_RELATIVE_PATH}": ${message}`, ); throw error; }}