Skip to content

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.

optiontypedefaultrequireddescription
contactstring | string[]-YesContact address(es). Each must be a mailto: address or an https:// URL.
expiresDate | string | SecurityTxtExpiresDuration-YesExpiry for the disclosure policy. Accepts a Date, ISO 8601 string, or duration like "1 year".
encryptionstring | string[]-NoHTTPS URL(s) to a PGP key or encryption information.
acknowledgmentsstring | string[]-NoHTTPS URL(s) to a page acknowledging security reporters.
preferredLanguagesstring | string[]-NoBCP 47 language tag(s) for the preferred language(s) of security reports.
canonicalstring | string[]-NoHTTPS URL(s) to the canonical location of this security.txt file.
policystring | string[]-NoHTTPS URL(s) to the security disclosure policy.
hiringstring | string[]-NoHTTPS URL(s) to security-related job listings.
csafstring | string[]-NoHTTPS URL(s) to a CSAF provider metadata file.

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".

astro.config.mjs
eminence({
securityTxt: {
contact: "mailto:security@example.com",
expires: "1 year",
},
});

Output at /.well-known/security.txt:

Contact: mailto:security@example.com
Expires: 2027-05-05T00:00:00.000Z
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.com
Contact: https://example.com/security
Expires: 2026-11-05T00:00:00.000Z
Preferred-Languages: en, fr
Policy: https://example.com/security-policy
eminence({
securityTxt: {
contact: "mailto:security@example.com",
expires: new Date("2027-01-01T00:00:00Z"),
},
});

Output:

Contact: mailto:security@example.com
Expires: 2027-01-01T00:00:00.000Z

All options provided explicitly.

Input:

astro.config.mjs
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.com
Contact: https://example.com/security
Expires: 2027-05-05T00:00:00.000Z
Encryption: https://example.com/pgp-key.asc
Acknowledgments: https://example.com/thanks
Preferred-Languages: 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
eminence({
securityTxt: false,
});

No file is generated and no warning is logged.

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.

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.

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.

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.

security-txt.ts
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;
}
}