ExpressNode.jsTypeScriptAudit LogsDeveloper GuideSOC 2

How to Add Audit Logs to an Express.js Application (2026 Guide)

AuditKit Team7 min read

If you are building a B2B SaaS API on Express.js (or Fastify, Koa, NestJS — most patterns transfer) and an enterprise customer just asked for SOC 2 evidence, this is the 10-minute version of adding tamper-evident audit logs. Everything below uses the AuditKit SDK, which works in any Node.js runtime and produces hash-chained, tenant-scoped events out of the box.

What Should an Express.js API Log for SOC 2?

SOC 2 auditors care about a specific subset of API events. The pattern is consistent:

  • Authentication events (sign in, sign out, token issuance, token revocation, password reset).
  • Organization and team events (create org, invite member, accept invite, change role, remove member).
  • Permission changes (grant role, revoke role, change scope on an API key).
  • Data access on sensitive resources (read of customer data, export of records, API key usage).
  • Configuration changes (security settings, integration tokens, webhook endpoints, billing changes).

Skip routine reads of public data, health checks, and status pings. A focused audit log is more credible to an auditor than a wide one with low signal-to-noise.

How Do I Install the AuditKit SDK?

npm install @auditkit/sdk
# or
pnpm add @auditkit/sdk

Add credentials to your environment:

AUDITKIT_API_KEY=sk_your_key_here
AUDITKIT_BASE_URL=https://api.auditkit.dev

Self-hosters point AUDITKIT_BASE_URL at their own deployment. The SDK reads both environment variables automatically.

Where Should the AuditKit Client Live?

Create one shared client instance in your infrastructure module so the SDK reuses HTTP connections across requests:

// src/lib/auditkit.ts
import { AuditKit } from '@auditkit/sdk';

export const auditLog = new AuditKit({
  apiKey: process.env.AUDITKIT_API_KEY!,
  baseURL: process.env.AUDITKIT_BASE_URL,
});

Import auditLog from this module wherever you need to record an event. The client is thread-safe and batches events asynchronously by default.

How Do I Log Events from Express Route Handlers?

The simplest pattern is to log directly inside route handlers after the business operation completes:

// src/routes/team.ts
import { Router } from 'express';
import { db } from '../lib/db';
import { auditLog } from '../lib/auditkit';
import { requireAuth } from '../middleware/auth';

const router = Router();

router.post('/invite', requireAuth, async (req, res) => {
  const { email, role } = req.body;
  const { userId, orgId } = req.session;

  const invite = await db.invite.create({
    data: { email, role, orgId, invitedBy: userId },
  });

  await auditLog.event({
    actor: userId,
    action: 'org.member.invite',
    resource: invite.id,
    tenantId: orgId,
    metadata: {
      inviteEmail: email,
      role,
      ipAddress: req.ip,
      userAgent: req.headers['user-agent'],
    },
  });

  res.json({ id: invite.id });
});

export default router;

The pattern is consistent: actor (who did it), action (dotted namespace), resource (what they did it to), tenantId (critical for multi-tenant SaaS), and metadata for anything else an auditor might want.

Should I Log from Express Middleware?

Sparingly — and selectively. Express middleware runs on every matching request, so logging there produces a lot of noise. Use middleware logging for two cases:

  1. Authentication failures and unauthorized access attempts — high-signal security events.
  2. Sensitive data exports (CSV, JSON, PDF) — auditors specifically want these tracked.
// src/middleware/auditExport.ts
import type { Request, Response, NextFunction } from 'express';
import { auditLog } from '../lib/auditkit';

export function auditExportMiddleware(req: Request, res: Response, next: NextFunction) {
  const isExport =
    req.path.endsWith('.csv') ||
    req.path.endsWith('.json') ||
    req.query.format === 'export';

  if (!isExport) return next();

  res.on('finish', () => {
    if (res.statusCode >= 200 && res.statusCode < 300 && req.session?.userId) {
      auditLog.event({
        actor: req.session.userId,
        action: 'data.export',
        resource: req.path,
        tenantId: req.session.orgId,
        metadata: {
          method: req.method,
          query: req.query,
          ipAddress: req.ip,
          responseSize: res.get('content-length'),
        },
      });
    }
  });

  next();
}

For everyday business events, log inside route handlers — never in catch-all middleware.

How Do I Show Each Tenant Their Own Audit Trail?

The most-requested enterprise feature is "let our customers see their own audit trail." With tenant-scoped events, this is a single endpoint:

// src/routes/audit.ts
import { Router } from 'express';
import { auditLog } from '../lib/auditkit';
import { requireAuth } from '../middleware/auth';

const router = Router();

router.get('/audit', requireAuth, async (req, res) => {
  const { orgId } = req.session;
  const { limit = 100, cursor } = req.query;

  const events = await auditLog.list({
    tenantId: orgId,
    limit: Number(limit),
    cursor: cursor as string | undefined,
    order: 'desc',
  });

  res.json(events);
});

export default router;

Because every event is tenant-scoped at the SDK layer, customer A never sees customer B's events. SOC 2 auditors specifically look for this isolation in the application code.

How Do I Handle High-Throughput Operations?

For bulk operations (data imports, batch user provisioning, scheduled jobs), use the SDK's batch interface to avoid one network round-trip per event:

// src/jobs/importContacts.ts
import { auditLog } from '../lib/auditkit';

export async function importContacts(records: Contact[], orgId: string, actorId: string) {
  const batch = auditLog.batch();

  for (const record of records) {
    await db.contact.create({ data: record });

    batch.event({
      actor: actorId,
      action: 'contact.create',
      resource: record.id,
      tenantId: orgId,
      metadata: { source: 'bulk-import', email: record.email },
    });
  }

  await batch.commit();
}

For a 10,000-record import, batched logging is roughly 50x faster than individual events because the SDK sends a single batch request with the full hash chain computed server-side.

How Do I Export Evidence for an Auditor?

At audit time, your auditor wants a tenant-scoped, time-bounded evidence export. AuditKit's export endpoint produces it directly:

// scripts/export-evidence.ts
import { auditLog } from '../src/lib/auditkit';
import { writeFile } from 'node:fs/promises';

const evidence = await auditLog.exportEvidence({
  tenantId: 'org_acme_corp',
  startDate: '2025-11-01',
  endDate: '2026-04-30',
  format: 'csv',
  includeChainProof: true,
});

await writeFile('acme-corp-audit-evidence.csv', evidence);

With includeChainProof: true, the export includes the cryptographic hash chain proof so the auditor can independently verify that no events were tampered with during the observation window. This is what makes the auditor's review fast: clean, verifiable evidence in a format they can read directly.

What About Performance? Will This Slow Down My API?

The AuditKit SDK dispatches events asynchronously by default. auditLog.event returns immediately; the network round-trip happens in the background. Typical overhead is under 0.5ms on the request path. For a 200ms-baseline API endpoint, audit logging adds well under 1% latency.

For graceful shutdown (SIGTERM, container restart), call auditLog.flush() in your shutdown handler to ensure any pending batched events are sent before the process exits:

process.on('SIGTERM', async () => {
  await auditLog.flush();
  server.close();
});

Do These Patterns Work With Fastify, Koa, NestJS?

Yes. The SDK is framework-agnostic. The patterns translate directly:

  • Fastify: Use the same patterns inside fastify.post() handlers and lifecycle hooks.
  • Koa: Use middleware functions with ctx instead of req/res; otherwise identical.
  • NestJS: Inject the AuditKit client as a service via the standard DI container; call from controllers or interceptors.

The mental model is the same regardless of framework: log structured events with actor, action, resource, and tenantId. The framework is just plumbing.

What Should I Do Next?

  • Audit your existing routes and add events for the SOC 2-relevant operations identified at the top of this post.
  • Add the audit-trail viewer endpoint so enterprise customers can self-serve their compliance evidence.
  • Configure SIEM streaming if you have Splunk, Datadog, or Elastic — events flow there in real time.
  • Run an evidence export for the previous 30 days as a smoke test, and verify the chain proof.
  • Document your event taxonomy so your team uses consistent action names going forward.

Key Takeaways

  • Add the AuditKit SDK in 4 lines: install, configure env vars, create the shared client, log events.
  • Log from route handlers for business events. Use middleware for security events and data exports only.
  • Always include tenantId — multi-tenant scoping is what makes the audit log enterprise-ready.
  • Use auditLog.batch() for high-throughput operations to keep latency low and throughput high.
  • Call auditLog.flush() in your SIGTERM handler so pending events ship before the process exits.
  • Evidence export with chain proof removes the week-before-the-audit scramble — the auditor verifies integrity independently.
  • Patterns transfer directly to Fastify, Koa, and NestJS — the SDK is framework-agnostic.

Ready to ship audit logging?

AuditKit gives you tamper-evident audit trails and SOC 2 evidence collection in one platform. Start free, or skip the trial below.

Related Articles