- Jun 5, 2026
- 4 min read
Expiring Report Links: Why My Audit Reports Don't Live Forever
Every audit run produces a folder of files: Lighthouse JSON, SEO and security reports, a master report that combines them. The result screen of my audit tool (webaudit.jalcocertech.com) has a “Full Report” button that opens the master JSON. The question was how to expose that file without turning the server into a public archive of other people’s websites.
The answer is two small mechanisms: random links that expire and run folders that delete themselves.
Why not just use the job ID?
Each run already has an id (randomUUID()), and it names the folder:
audit-runs/<jobId>/. Serving /reports/<jobId> would have been the obvious
route. I didn’t, for two reasons:
- The job id appears in server logs and in folder names. Anything that ends up in logs shouldn’t double as an access key.
- A link that works forever is a link that gets forwarded, indexed or scraped. An audit report lists weaknesses of someone’s site; it shouldn’t outlive its usefulness.
So the report gets its own random token, separate from the job id.
The token map
const reportToken = randomUUID();
reportAccessTokens.set(reportToken, {
reportsRoot,
masterFile: latestFile,
createdAt: Date.now(),
});
cleanupReportTokens();
reportAccessTokens is a plain in-memory Map from token to the run’s
folder and file. The browser receives reportUrl: /api/reports/<token>/master
and nothing else about where the file lives.
Opening the link looks the token up, checks the age, and serves the file:
app.get('/api/reports/:token/master', (req, res) => {
cleanupReportTokens();
const report = reportAccessTokens.get(req.params.token);
if (!report) {
return res.status(404).json({ error: 'Report not found or expired' });
}
const reportPath = path.join(report.reportsRoot, 'master-reports', report.masterFile);
if (!reportPath.startsWith(report.reportsRoot) || !fs.existsSync(reportPath)) {
return res.status(404).json({ error: 'Report not found' });
}
res.type('application/json').sendFile(reportPath);
});
The startsWith check is belt and braces: the path comes from the server’s
own map, not from the request, but if anything ever changed that, the server
still refuses to serve a file outside the run folder.
Expiry
cleanupReportTokens() drops every token older than REPORT_TOKEN_TTL_MS
(24 hours by default). It runs whenever a token is created or opened, so
there’s no separate timer for it.
sequenceDiagram
participant B as Browser
participant S as Audit server
participant D as Disk (audit-runs/jobId)
B->>S: POST /api/audit
S->>D: run scripts, write reports
S->>S: token = randomUUID(), map token to run folder
S-->>B: result + reportUrl /api/reports/token/master
B->>S: GET /api/reports/token/master
S->>S: drop tokens older than 24 h, look up token
S->>D: read master report
S-->>B: 200 JSON
Note over B,S: 24 hours later
B->>S: GET /api/reports/token/master
S-->>B: 404 Report not found or expired
Run folders delete themselves
Tokens only control access; the files would still pile up on disk. A second
cleanup walks audit-runs/ and removes any run folder whose modification time
is older than AUDIT_RUN_RETENTION_MS (7 days by default):
if (now - stat.mtimeMs > auditRunRetentionMs) {
fs.rmSync(runPath, { recursive: true, force: true });
}
It runs once at startup and then every AUDIT_RUN_CLEANUP_INTERVAL_MS
(hourly by default). The gap between the two numbers is intentional: the link
dies after a day, the files stay a few more days in case I need to debug a
run someone reported as wrong, then they’re gone.
| Thing | Lifetime | Setting |
|---|---|---|
| Report link (token) | 24 hours | REPORT_TOKEN_TTL_MS |
| Run folder on disk | 7 days | AUDIT_RUN_RETENTION_MS |
| Cleanup sweep | every hour | AUDIT_RUN_CLEANUP_INTERVAL_MS |
The honest limitation: restarts
The token map lives in memory. When the server restarts — a deploy, a crash,
a container recreate — every outstanding link stops working, even if it
was issued five minutes earlier. I’ve seen it in the logs: a
GET /api/reports/<token>/master 404 right after a redeploy.
For this tool that’s acceptable. The result screen already shows the diagnosis, scores and top issues; the full report is an extra, and running a new audit takes twenty seconds. If reports ever become the product (emailed, shared with a team), the map has to move to persistent storage with the same expiry rule.
Why not keep reports forever?
- They describe someone else’s site. A list of missing security headers is useful to the owner and to an attacker; it shouldn’t sit on a server indefinitely.
- They go stale fast. A performance report from three months ago says little about the site today.
- Disk is finite on a homelab. Lighthouse JSON is a few hundred kilobytes per run; it adds up.
Takeaways
- Never reuse an internal id as an access key; mint a separate random token.
- Expire access and data separately: short-lived links, slightly longer-lived files.
- In-memory tokens are fine until links need to survive a restart — know which side of that line you’re on.
- Default to deleting. Keeping data is the decision that needs a reason.