Audit Trails for CRUD Apps: Why You Need Them and How to Build Them in Hono + MongoDB
Imagine this scenario:
You’re the lead developer on a sleek new admin panel for a chain of resorts. One morning, the Operations Manager calls in a panic:
“Someone deleted the evening reservation for 50 guests… and we can’t figure out who did it!”
Yikes. 😬
This is exactly the kind of chaos audit trails are designed to prevent. In this post, we’ll explore:
- Why auditing CRUD operations is essential
- Snapshots vs. deltas
- A step-by-step breakdown of an audit middleware for Hono + MongoDB
- Fun examples of routes and the audit logs they produce
- Best practices and tips for production-ready auditing
Let’s dive in.
Why Audit CRUD Operations?
CRUD operations—Create, Read, Update, Delete—are the core of most applications. But if you’re only logging “user X did Y,” you’re missing the juicy details:
- Who changed what?
- What was the previous value?
- When did it happen?
Auditing CRUD operations helps you:
- Track critical actions – Know exactly who deleted, updated, or created a resource.
- Debug faster – Restore previous states using snapshots.
- Meet compliance requirements – Many industries (finance, healthcare) demand audit logs.
- Detect suspicious activity – Spot anomalies like repeated deletes or strange updates.
Snapshots vs. Deltas
When storing audit logs, there are two main strategies:
Full Snapshots
- Store the entire object before and/or after the operation.
- Ideal for DELETE or POST where you care about the whole resource.
Example: Delete an outlet
before: {
"name": "Sunset Bar",
"location": "Maldives",
"status": "active"
}
after: null
Deltas
- Store only the fields that changed.
- Ideal for PUT / UPDATE requests.
Example: Update outlet status
before: { "status": "active" }
after: { "status": { "before": "active", "after": "inactive" } }
This makes logs compact and readable while keeping the necessary detail.
Introducing the Audit Middleware
Here’s a Hono + MongoDB middleware we built to handle auditing automatically for CRUD operations.
import type { MiddlewareHandler } from "hono";
import Audit from "../models/AppAudit.js";
import type { ISessionContext } from "../types/Sessions.js";
import type { AuditOptions } from "../types/Audit.js";
export const auditMiddleware = (
options: AuditOptions = {},
): MiddlewareHandler => {
const { action: customAction, saveBody = true, model: ModelProvided, isCrud = true } = options;
return async (c: ISessionContext, next: () => Promise<void>) => {
const method = c.req.method;
const pathSegments = c.req.path.split("/").filter(Boolean);
const resourceType = pathSegments[0] || "unknown";
const resourceId = c.req.param("id") || null;
const user = c.get("user")?._id ?? null;
const CRITICAL_METHODS = ["DELETE", "POST"];
const isCritical = CRITICAL_METHODS.includes(method.toUpperCase());
let before: any = null;
if (isCrud && isCritical && resourceId) {
try {
const Model =
ModelProvided ||
(await import(`../models/${capitalize(resourceType)}.js`)).default;
before = await Model.findById(resourceId).lean();
} catch (err) {
console.warn(`Audit: failed to fetch snapshot for ${resourceType} ${resourceId}`, err);
}
}
await next(); // execute route handler
let reqBody: any = null;
if (saveBody) {
try {
reqBody = await c.req.json();
const forbiddenKeys = ["password", "token", "apiKey", "secret"];
const sanitize = (obj: any): any => {
if (Array.isArray(obj)) return obj.map(sanitize);
if (obj && typeof obj === "object") {
return Object.fromEntries(
Object.entries(obj)
.filter(([key]) => !forbiddenKeys.includes(key.toLowerCase()))
.map(([key, value]) => [key, sanitize(value)]),
);
}
return obj;
};
reqBody = sanitize(reqBody);
} catch (_) {
reqBody = null;
}
}
let after: any = null;
if (isCrud) {
if (isCritical) {
after = reqBody || null;
} else if (before && reqBody) {
after = {};
for (const key of Object.keys(reqBody)) {
if (before[key] !== reqBody[key]) {
after[key] = { before: before[key], after: reqBody[key] };
}
}
if (Object.keys(after).length === 0) after = null;
} else {
after = reqBody || null;
}
} else {
after = reqBody || null;
}
const action =
customAction || (isCrud ? `${method.toLowerCase()}${capitalize(resourceType)}` : "custom");
try {
await Audit.create({
action,
resourceType,
resourceId,
user,
isCrud,
timestamp: new Date(),
before,
after,
});
} catch (err) {
console.error("Audit save failed", err);
}
};
};
function capitalize(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
Middleware Breakdown
- Options & Defaults – Customize the action name, saveBody, model, and whether it’s a CRUD operation.
- Identify Resource & User – Extract resource type, ID, and current user:
const user = c.get("user")?._id ?? null;
Note: This assumes that the request context (c) includes authentication details. For authenticated routes, the user variable should be present in the context. If the route is unauthenticated, user will be null.
- Determine Critical Operations – DELETE always critical, POST optionally critical.
- Before Snapshot – Captures document from MongoDB before critical actions.
- Execute Route Handler – Proceed with the database operation.
- Request Body Capture & Sanitization – Avoid logging sensitive fields.
- After Snapshot / Delta – Full snapshot for critical, delta for updates.
- Action Name – Defaults to
method + resourceType. - Save Audit Log – Stores everything in MongoDB’s
Auditcollection.
Example Routes
CRUD Example: Outlets
import { Hono } from "hono";
import { auditMiddleware } from "./middleware/audit.js";
import Outlet from "./models/Outlet.js";
const app = new Hono();
// Create Outlet (full snapshot)
app.post("/outlets", auditMiddleware(), async (c) => {
const body = await c.req.json();
const outlet = await Outlet.create(body);
return c.json(outlet);
});
// Update Outlet (delta)
app.put("/outlets/:id", auditMiddleware(), async (c) => {
const id = c.req.param("id");
const body = await c.req.json();
const outlet = await Outlet.findByIdAndUpdate(id, body, { new: true });
return c.json(outlet);
});
// Delete Outlet (full snapshot)
app.delete("/outlets/:id", auditMiddleware(), async (c) => {
const id = c.req.param("id");
await Outlet.findByIdAndDelete(id);
return c.json({ success: true });
});
Non-CRUD Example: User Login
app.post("/login", auditMiddleware({ isCrud: false, action: "userLogin" }), async (c) => {
const { email, password } = await c.req.json();
const user = await User.findOne({ email });
return c.json({ success: !!user });
});
Example Audit Documents
Full Snapshot (POST / DELETE)
{
"action": "postOutlets",
"resourceType": "outlets",
"resourceId": "653ab123cd45ef6789012345",
"user": "64fa123bcdef456789012345",
"isCrud": true,
"timestamp": "2025-10-18T00:00:00.000Z",
"before": null,
"after": {
"name": "Sunset Bar",
"location": "Maldives",
"status": "active"
}
}
Delta (PUT / UPDATE)
{
"action": "putOutlets",
"resourceType": "outlets",
"resourceId": "653ab123cd45ef6789012345",
"user": "64fa123bcdef456789012345",
"isCrud": true,
"timestamp": "2025-10-18T01:00:00.000Z",
"before": {
"name": "Sunset Bar",
"location": "Maldives",
"status": "active"
},
"after": {
"status": { "before": "active", "after": "inactive" }
}
}
Non-CRUD Action (Login)
{
"action": "userLogin",
"resourceType": "unknown",
"resourceId": null,
"user": null,
"isCrud": false,
"timestamp": "2025-10-18T02:00:00.000Z",
"before": null,
"after": { "email": "[email protected]" }
}
Best Practices
- Indexing – Index
resourceType,resourceId, andtimestampfor fast lookups. - Archive Old Logs – Move old audits to another collection or storage to avoid bloating.
- Sanitize Sensitive Data – Never store passwords, tokens, or secrets.
- Handle Large Payloads – Consider truncating or summarizing extremely large bodies.
- Attach Middleware Selectively – You don’t need auditing on GET routes unless needed.
Visualizing Snapshots vs Deltas
[PUT /outlets/:id]
Before: { name: "Sunset Bar", status: "active" }
After: { name: "Sunset Bar", status: "inactive" }
Audit Delta:
{ status: { before: "active", after: "inactive" } }
[DELETE /outlets/:id]
Before: { name: "Sunset Bar", status: "active" }
After: null
Audit Log: full snapshot
Conclusion
Auditing CRUD operations is not optional if you care about:
- Data integrity
- Debugging & troubleshooting
- Security & compliance
With this middleware, your Hono + MongoDB apps gain transparent, reliable audit trails with minimal effort. You can track every change, recover from mistakes, and even detect suspicious behavior.
Next time someone panics about a missing record, you can calmly say:
“No worries, we’ve got the audit logs!” 😎
