Enterprise clients eventually ask the same question: "Who changed this record, when, and what was it before?" If your MEAN app answers with "we can check the logs", you have a problem. This tutorial builds a tamper-evident audit trail: a Mongoose 8 plugin that records field-level diffs, request context carried through Express 5 with AsyncLocalStorage, a read-only API, and an Angular 22 history view built with signals.
What we are building
- Every create, update and delete on audited models writes an immutable entry to an
auditlogscollection. - Each entry stores the actor, request ID, action, and a field-level before/after diff, with sensitive fields redacted.
- Entries are hash-chained per document, so deleting or editing one is detectable.
- An admin-only endpoint and an Angular component display the history.
Prerequisites: Node.js 24 LTS, Express 5, Mongoose 8, MongoDB 8 and Angular 22, as in our modern MEAN app tutorial.
Step 1: carry request context with AsyncLocalStorage
Mongoose hooks have no access to req. Rather than pass the user into every call, store it in an async context set once by middleware.
// api/src/context.js
import { AsyncLocalStorage } from 'node:async_hooks';
export const als = new AsyncLocalStorage();
export function currentContext() {
return als.getStore() ?? {};
}
// api/src/middleware/context.js
import { randomUUID } from 'node:crypto';
import { als } from '../context.js';
export function requestContext(req, res, next) {
const store = {
requestId: req.get('x-request-id') ?? randomUUID(),
actor: null, // filled in after authentication
ip: req.ip,
};
res.setHeader('x-request-id', store.requestId);
als.run(store, next);
}
// Mount after your auth middleware so req.user exists.
export function bindActor(req, _res, next) {
const store = als.getStore();
if (store && req.user) store.actor = { id: String(req.user.id), email: req.user.email };
next();
}
Mount requestContext first, then authentication, then bindActor. Because the store object is mutated rather than replaced, the actor is visible to everything downstream in the same request.
Step 2: the audit log model
// api/src/models/auditLog.js
import { Schema, model } from 'mongoose';
const auditSchema = new Schema({
collectionName: { type: String, required: true },
documentId: { type: Schema.Types.ObjectId, required: true },
action: { type: String, enum: ['create', 'update', 'delete'], required: true },
actor: { id: String, email: String },
requestId: String,
ip: String,
changes: { type: Schema.Types.Mixed, default: {} }, // { field: { from, to } }
seq: { type: Number, required: true },
prevHash: { type: String, default: '' },
hash: { type: String, required: true },
}, { timestamps: { createdAt: true, updatedAt: false } });
auditSchema.index({ documentId: 1, seq: 1 }, { unique: true });
auditSchema.index({ collectionName: 1, createdAt: -1 });
// Immutability at the application layer
for (const op of ['updateOne', 'updateMany', 'findOneAndUpdate', 'replaceOne', 'deleteOne', 'deleteMany', 'findOneAndDelete']) {
auditSchema.pre(op, function () {
throw new Error('Audit log entries are immutable');
});
}
export const AuditLog = model('AuditLog', auditSchema);
The unique (documentId, seq) index matters: if two writers race to append, one fails instead of forking the chain. Application-level immutability is not enough on its own, so in production also give the app's database user insert and find rights only on this collection, with no update or remove.
Step 3: the plugin
// api/src/plugins/audit.js
import { createHash } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import { AuditLog } from '../models/auditLog.js';
import { currentContext } from '../context.js';
const ALWAYS_SKIP = new Set(['_id', '__v', 'updatedAt', 'createdAt']);
function diff(before, after, redact) {
const out = {};
const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]);
for (const k of keys) {
if (ALWAYS_SKIP.has(k)) continue;
if (isDeepStrictEqual(before?.[k], after?.[k])) continue;
out[k] = redact.has(k)
? { from: '[redacted]', to: '[redacted]' }
: { from: before?.[k] ?? null, to: after?.[k] ?? null };
}
return out;
}
async function append(collectionName, documentId, action, changes) {
const ctx = currentContext();
const last = await AuditLog.findOne({ documentId }).sort({ seq: -1 }).lean();
const seq = (last?.seq ?? 0) + 1;
const prevHash = last?.hash ?? '';
const payload = JSON.stringify({ collectionName, documentId, action, changes, seq, prevHash, actor: ctx.actor?.id ?? null });
const hash = createHash('sha256').update(payload).digest('hex');
await AuditLog.create({
collectionName, documentId, action, changes, seq, prevHash, hash,
actor: ctx.actor, requestId: ctx.requestId, ip: ctx.ip,
});
}
export function auditPlugin(schema, { redact = [] } = {}) {
const redactSet = new Set(redact);
// Document saves: create and update via doc.save()
schema.pre('save', async function () {
this.$locals.wasNew = this.isNew;
this.$locals.before = this.isNew ? {} : (await this.constructor.findById(this._id).lean()) ?? {};
});
schema.post('save', async function (doc) {
const after = doc.toObject({ depopulate: true });
const changes = diff(doc.$locals.before, after, redactSet);
if (!doc.$locals.wasNew && Object.keys(changes).length === 0) return;
await append(doc.constructor.modelName, doc._id, doc.$locals.wasNew ? 'create' : 'update', changes);
});
// Query updates: findOneAndUpdate
schema.pre('findOneAndUpdate', async function () {
this._auditBefore = await this.model.findOne(this.getFilter()).lean();
});
schema.post('findOneAndUpdate', async function (after) {
if (!after || !this._auditBefore) return;
const changes = diff(this._auditBefore, after.toObject({ depopulate: true }), redactSet);
if (Object.keys(changes).length) await append(this.model.modelName, after._id, 'update', changes);
});
// Deletes: keep a final snapshot, redacted
schema.post('findOneAndDelete', async function (doc) {
if (!doc) return;
const snapshot = diff(doc.toObject({ depopulate: true }), {}, redactSet);
await append(this.model.modelName, doc._id, 'delete', snapshot);
});
}
Apply it to a model:
import { auditPlugin } from '../plugins/audit.js';
taskSchema.plugin(auditPlugin, { redact: ['passwordHash', 'apiKey'] });
Know the limits. updateOne, updateMany, bulkWrite and insertMany bypass these hooks. Either ban them on audited models in code review, or add dedicated hooks. For anything that must never be missed, consider MongoDB change streams (see our real-time change streams tutorial) as a second, hook-independent recorder.
Step 4: a read-only API with Express 5
Express 5 forwards rejected promises from async handlers to the error handler, so no wrapper is needed.
// api/src/routes/audit.js
import { Router } from 'express';
import { createHash } from 'node:crypto';
import { z } from 'zod';
import { AuditLog } from '../models/auditLog.js';
export const auditRouter = Router();
const params = z.object({ id: z.string().regex(/^[a-f\d]{24}$/i) });
auditRouter.get('/:id', async (req, res) => {
if (req.user?.role !== 'admin') return res.sendStatus(403);
const { id } = params.parse(req.params);
const entries = await AuditLog.find({ documentId: id }).sort({ seq: 1 }).lean();
let prev = '';
let intact = true;
for (const e of entries) {
const payload = JSON.stringify({
collectionName: e.collectionName, documentId: e.documentId, action: e.action,
changes: e.changes, seq: e.seq, prevHash: prev, actor: e.actor?.id ?? null,
});
const expected = createHash('sha256').update(payload).digest('hex');
if (e.prevHash !== prev || e.hash !== expected) { intact = false; break; }
prev = e.hash;
}
res.json({ intact, entries });
});
Note that the verification recomputes the same payload, with the same key order, that the plugin hashed. Keep the two in one shared helper in real code. Be aware that JSON.stringify of Mixed changes containing Date or ObjectId values is stable, but if you reorder keys in the diff function the stored hashes will no longer verify.
A hash chain detects tampering; it does not prevent it. For stronger guarantees, periodically write the latest hash to storage the application cannot modify, such as object storage with retention locks.
Step 5: the Angular 22 history view
// web/src/app/audit-history.ts
import { Component, inject, input, resource } from '@angular/core';
import { DatePipe, JsonPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
interface Entry {
seq: number; action: string; createdAt: string;
actor?: { email: string };
changes: Record<string, { from: unknown; to: unknown }>;
}
@Component({
selector: 'app-audit-history',
imports: [DatePipe, JsonPipe],
template: `
@if (history.isLoading()) { <p>Loading history…</p> }
@if (history.error()) { <p role="alert">Could not load history.</p> }
@if (history.value(); as h) {
@if (!h.intact) {
<p role="alert"><strong>Warning:</strong> this history failed integrity verification.</p>
}
<ol>
@for (e of h.entries; track e.seq) {
<li>
<strong>{{ e.action }}</strong> by {{ e.actor?.email ?? 'system' }}
on {{ e.createdAt | date: 'medium' }}
<ul>
@for (c of entries(e.changes); track c[0]) {
<li>{{ c[0] }}: {{ c[1].from | json }} → {{ c[1].to | json }}</li>
}
</ul>
</li>
}
</ol>
}
`,
})
export class AuditHistory {
private http = inject(HttpClient);
documentId = input.required<string>();
history = resource({
params: () => ({ id: this.documentId() }),
loader: ({ params }) =>
firstValueFrom(this.http.get<{ intact: boolean; entries: Entry[] }>(`/api/audit/${params.id}`)),
});
entries(c: Entry['changes']) { return Object.entries(c); }
}
Angular's template escapes interpolated values, so a malicious field value cannot inject markup. Do not switch to innerHTML to render diffs.
Step 6: test it
Using the Node test runner and a real MongoDB (see our Testcontainers guide):
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { als } from '../src/context.js';
import { Task } from '../src/models/task.js';
import { AuditLog } from '../src/models/auditLog.js';
test('update writes a redacted, chained diff entry', async () => {
await als.run({ requestId: 'r1', actor: { id: 'u1', email: 'a@example.com' } }, async () => {
const t = await Task.create({ title: 'one', apiKey: 'secret' });
t.title = 'two';
t.apiKey = 'secret2';
await t.save();
const log = await AuditLog.find({ documentId: t._id }).sort({ seq: 1 });
assert.equal(log.length, 2);
assert.deepEqual(log[1].changes.title, { from: 'one', to: 'two' });
assert.equal(log[1].changes.apiKey.to, '[redacted]');
assert.equal(log[1].prevHash, log[0].hash);
});
});
Production notes
- Retention: audit collections grow forever. Add a TTL or archive policy that matches your compliance requirement, and never apply it to the hash chain head without recording it elsewhere.
- Write cost: each audited save does an extra read and an extra write. Measure with the approach in our index tuning article; the
documentId + seqindex keeps the lookup cheap. - PII: redact by allow-list where possible. A deny-list only protects the fields you remembered.
- Transactions: if the business write and the audit write must succeed or fail together, run both in a MongoDB transaction session. The plugin above writes outside the transaction for simplicity.
Need an audit trail in your own MEAN app?
Compliance-driven audit logging touches the data model, the API and the UI. If you need it designed and built, or want an existing MEAN application reviewed for gaps, get in touch and we will scope it with you.