Log LLM prompts safely with redaction, hashing, and short retention
For developers who need security-reviewable LLM logs without turning observability into a privacy problem. This shows a concrete logging pipeline with request IDs, deterministic redaction, salted hashes, and database retention so reviewers can investigate abuse and prompt injection without storing raw personal data.
TL;DR — Log metadata, not raw prompts by default: request ID, timestamp, model, token counts, decision flags, and a redacted prompt/response. Replace emails, phones, secrets, and long numbers before logging; store salted hashes for correlation; keep raw content out of persistent logs unless a short-lived incident flag is explicitly enabled. Reading time: ~5 min
Goal
When you finish, your app will write LLM interaction audit records that are useful for security review—request IDs, model/provider metadata, redacted content, risk flags, and stable hashes for correlation—while avoiding persistent storage of unnecessary personal data and automatically deleting old records.
Prerequisites
- Shell access to the app host or CI runner that builds your service
- Node.js >= 20 — check with:
node --version
- PostgreSQL >= 14 — check with:
psql --version
- A running app where you can add one middleware/wrapper around LLM calls
- A database URL with rights to create a table and index, for example
postgres://app:***@db.example.internal:5432/app - An application secret for HMAC hashing, 32+ random bytes, available as
LOG_HASH_KEY - Your log shipper or stdout collector already in place if you centralize logs
Steps
Step 1: Create an audit table with retention-friendly columns
Run this SQL against your application database:
psql "$DATABASE_URL" <<'SQL'
CREATE TABLE IF NOT EXISTS llm_audit_log (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
request_id TEXT NOT NULL,
actor_type TEXT NOT NULL,
actor_hash TEXT,
session_hash TEXT,
provider TEXT NOT NULL,
model TEXT NOT NULL,
endpoint TEXT NOT NULL,
prompt_redacted TEXT,
response_redacted TEXT,
prompt_hash TEXT,
response_hash TEXT,
prompt_tokens INTEGER,
response_tokens INTEGER,
latency_ms INTEGER,
risk_flags JSONB NOT NULL DEFAULT '[]'::jsonb,
blocked BOOLEAN NOT NULL DEFAULT false,
http_status INTEGER,
retention_until TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_llm_audit_created_at
ON llm_audit_log (created_at);
CREATE INDEX IF NOT EXISTS idx_llm_audit_request_id
ON llm_audit_log (request_id);
CREATE INDEX IF NOT EXISTS idx_llm_audit_actor_hash
ON llm_audit_log (actor_hash);
CREATE INDEX IF NOT EXISTS idx_llm_audit_retention_until
ON llm_audit_log (retention_until);
SQL
Success looks like:
CREATE TABLE
CREATE INDEX
CREATE INDEX
CREATE INDEX
CREATE INDEX
Step 2: Add a deterministic redaction and hashing module
Create src/llmAudit.js with these exact contents:
import crypto from 'node:crypto';
const HASH_KEY = process.env.LOG_HASH_KEY;
if (!HASH_KEY || HASH_KEY.length < 32) {
throw new Error('LOG_HASH_KEY must be set to at least 32 characters');
}
export function hmac(value) {
if (!value) return null;
return crypto.createHmac('sha256', HASH_KEY).update(String(value)).digest('hex');
}
export function redact(input) {
if (!input) return input;
return String(input)
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[REDACTED_EMAIL]')
.replace(/\+?[0-9][0-9()\-\s]{7,}[0-9]/g, '[REDACTED_PHONE]')
.replace(/\b(?:\d[ -]*?){13,19}\b/g, '[REDACTED_LONG_NUMBER]')
.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[REDACTED_SSN]')
.replace(/\b(sk_live|sk_test|pk_live|pk_test|ghp|github_pat|xoxb|xoxp)_[A-Za-z0-9_\-]+\b/g, '[REDACTED_API_KEY]')
.replace(/-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----/g, '[REDACTED_PRIVATE_KEY]');
}
export function riskFlags(input) {
const s = String(input || '');
const flags = [];
if (/ignore previous instructions|system prompt|developer message/i.test(s)) flags.push('prompt_injection_terms');
if (/password|api[_ -]?key|token|secret|private key/i.test(s)) flags.push('credential_terms');
if (/ssn|social security|passport|driver'?s license|date of birth/i.test(s)) flags.push('sensitive_identity_terms');
return flags;
}
Success looks like your app starting without this error:
Error: LOG_HASH_KEY must be set to at least 32 characters
Step 3: Wrap LLM calls and write only redacted content to the database
Add this wrapper as src/loggedLlmCall.js:
import pg from 'pg';
import { hmac, redact, riskFlags } from './llmAudit.js';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
export async function loggedLlmCall({ requestId, actorType, actorId, sessionId, provider, model, endpoint, prompt, call }) {
const started = Date.now();
const promptRedacted = redact(prompt);
const promptFlags = riskFlags(prompt);
let result, blocked = false, httpStatus = null;
if (promptFlags.includes('credential_terms')) {
blocked = true;
httpStatus = 400;
await pool.query(
`INSERT INTO llm_audit_log
(request_id, actor_type, actor_hash, session_hash, provider, model, endpoint,
prompt_redacted, prompt_hash, prompt_tokens, response_tokens, latency_ms,
risk_flags, blocked, http_status, retention_until)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15, now() + interval '30 days')`,
[requestId, actorType, hmac(actorId), hmac(sessionId), provider, model, endpoint,
promptRedacted, hmac(promptRedacted), null, null, Date.now() - started,
JSON.stringify(promptFlags), true, httpStatus]
);
throw new Error('blocked prompt: credential_terms');
}
try {
result = await call();
httpStatus = 200;
} catch (err) {
httpStatus = err.status || 500;
throw err;
} finally {
const responseText = result?.text || '';
const responseRedacted = redact(responseText);
const flags = [...new Set([...promptFlags, ...riskFlags(responseText)])];
await pool.query(
`INSERT INTO llm_audit_log
(request_id, actor_type, actor_hash, session_hash, provider, model, endpoint,
prompt_redacted, response_redacted, prompt_hash, response_hash,
prompt_tokens, response_tokens, latency_ms, risk_flags, blocked, http_status, retention_until)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17, now() + interval '30 days')`,
[requestId, actorType, hmac(actorId), hmac(sessionId), provider, model, endpoint,
promptRedacted, responseRedacted, hmac(promptRedacted), hmac(responseRedacted),
result?.usage?.input_tokens ?? null, result?.usage?.output_tokens ?? null,
Date.now() - started, JSON.stringify(flags), blocked, httpStatus]
);
}
return result;
}
Success looks like your app still returning normal LLM responses, with one new row per call in llm_audit_log.
Step 4: Call the wrapper from your request handler
Replace your direct LLM call with the wrapper. Example Express route:
import crypto from 'node:crypto';
import express from 'express';
import { loggedLlmCall } from './loggedLlmCall.js';
const app = express();
app.use(express.json({ limit: '256kb' }));
app.post('/api/ask', async (req, res) => {
const requestId = req.get('x-request-id') || crypto.randomUUID();
try {
const result = await loggedLlmCall({
requestId,
actorType: 'user',
actorId: req.user.id,
sessionId: req.session.id,
provider: 'openai-compatible',
model: 'gpt-4.1-mini',
endpoint: '/v1/responses',
prompt: req.body.prompt,
call: async () => {
return await yourLlmClient.responses.create({
model: 'gpt-4.1-mini',
input: req.body.prompt
});
}
});
res.set('x-request-id', requestId).json({ requestId, text: result.output_text || result.text });
} catch (err) {
res.set('x-request-id', requestId).status(err.message.includes('blocked prompt') ? 400 : 500).json({ requestId, error: err.message });
}
});
Success looks like clients receiving an x-request-id header they can quote during review.
Step 5: Add automatic deletion for expired records
⚠️ This step deletes data. Run the
SELECT count(*)first if you want to see how many rows will be removed.
Create a daily cleanup script:
cat > /usr/local/bin/llm-audit-retention.sh <<'SH'
#!/usr/bin/env bash
set -euo pipefail
psql "$DATABASE_URL" -c "DELETE FROM llm_audit_log WHERE retention_until < now();"
SH
chmod +x /usr/local/bin/llm-audit-retention.sh
Install a cron entry:
( crontab -l 2>/dev/null; echo '17 2 * * * /usr/local/bin/llm-audit-retention.sh >> /var/log/llm-audit-retention.log 2>&1' ) | crontab -
crontab -l
Success looks like this line present in crontab -l:
17 2 * * * /usr/local/bin/llm-audit-retention.sh >> /var/log/llm-audit-retention.log 2>&1
Step 6: Keep raw content out of application logs
If your app logs request bodies, disable that now. For a common Express setup using Morgan, log only method, path, status, and request ID:
import morgan from 'morgan';
morgan.token('request-id', (req) => req.get('x-request-id') || '-');
app.use(morgan(':method :url :status :res[content-length] - :response-time ms req_id=:request-id'));
Success looks like lines shaped like this, with no prompt text:
POST /api/ask 200 842 - 913.442 ms req_id=8f2d0b3f-6f5a-4d4d-9f8a-3d26a1f7d8c7
Verify it works
Send a prompt containing obvious PII and inspect the stored record:
curl -sS -X POST http://localhost:3000/api/ask \
-H 'Content-Type: application/json' \
-H 'x-request-id: test-redaction-001' \
-d '{"prompt":"My email is alice@example.com and my phone is +1 415 555 0101. Summarize this."}' | jq .
psql "$DATABASE_URL" -x -c "SELECT request_id, prompt_redacted, prompt_hash, actor_hash, risk_flags, blocked, http_status FROM llm_audit_log WHERE request_id = 'test-redaction-001' ORDER BY id DESC LIMIT 1;"
Expected output shape:
{
"requestId": "test-redaction-001",
"text": "..."
}
-[ RECORD 1 ]---+-----------------------------------------------------------------
request_id | test-redaction-001
prompt_redacted | My email is [REDACTED_EMAIL] and my phone is [REDACTED_PHONE]. Summarize this.
prompt_hash | 6f7d7d8d5d6c0d7a8b2d2f4b1c0e5a2d4d7f...
actor_hash | 2c9c0d0b0e2f4a7d1f6a...
risk_flags | []
blocked | f
http_status | 200
Also verify blocking works for obvious secrets:
curl -i -sS -X POST http://localhost:3000/api/ask \
-H 'Content-Type: application/json' \
-d '{"prompt":"Here is my API key sk_live_1234567890abcdef. Fix my code."}'
Expected status line:
HTTP/1.1 400 Bad Request
x-request-id: 1d4b1c2e-...
content-type: application/json; charset=utf-8
Common pitfalls
Logging the raw request body before redaction
Mistake: middleware or reverse proxy access logs include req.body.prompt.
Symptom: you can find full prompts with emails or secrets in stdout, journald, or your log aggregator even though llm_audit_log is redacted.
Fix: remove body logging and use a format string that logs only method, path, status, latency, and request ID.
Using plain SHA-256 instead of HMAC for user correlation
Mistake: hashing emails or user IDs with unsalted SHA-256.
Symptom: the same actor hashes are vulnerable to dictionary attacks for common emails and IDs.
Fix: replace plain hashes with HMAC-SHA256(secret, value) and rotate LOG_HASH_KEY through your secret manager.
Redacting after persistence
Mistake: inserting raw prompt/response into the database and planning to scrub it later.
Symptom: backups, replicas, WAL archives, and CDC streams already contain the raw data.
Fix: call redact() before any INSERT, and do not emit raw content to logs, traces, or metrics labels.
Retention job exists but never runs in containers
Mistake: adding a cron entry on an image or ephemeral container where cron is not running.
Symptom: retention_until is in the past but rows remain for weeks.
Fix: run the delete command from your platform scheduler, CI scheduled job, or a dedicated maintenance container instead of relying on local cron.
Storing too much in risk flags or metadata
Mistake: copying user-provided strings into risk_flags, exception messages, or tracing attributes.
Symptom: reviewers search the safe columns and still find personal data.
Fix: keep risk_flags to fixed enum-like values such as prompt_injection_terms, credential_terms, and sensitive_identity_terms; do not store free-form user text outside redacted fields.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI