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

ThingLifetimeSetting
Report link (token)24 hoursREPORT_TOKEN_TTL_MS
Run folder on disk7 daysAUDIT_RUN_RETENTION_MS
Cleanup sweepevery hourAUDIT_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.

Next: checking HTTPS, certificates and TLS like a visitor.