ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

ยฉ 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls ยท Full content ownership
All systems operational
HomeAudit Logging in Node.js: Who Did What, When, and How to Prove It

Audit Logging in Node.js: Who Did What, When, and How to Prove It

Immutable PostgreSQL audit logs with GDPR-safe tracking, DSAR-ready queries, and append-only enforcement for compliance-critical systems.

ZyVOP
ZyVOP
Senior Developer
May 28, 2026
7 min read
Audit Logging in Node.js: Who Did What, When, and How to Prove It
#audit logging Node.js#GDPR audit trail PostgreSQL#audit log schema PostgreSQL#Node.js compliance logging#GDPR Article 30 Node.js#data subject access request Node.js#append-only audit log#GDPR logging 2026

Most applications log errors. Fewer log the events that matter to the business: who changed a permission, who exported a CSV of customer data, who deleted a record that cannot be recovered, who approved a payment. These are the events that a regulator, an auditor, a support team, or a forensic investigation needs to reconstruct what happened.

Application logs and audit logs are different things. Application logs are operational โ€” they tell you what your system did. Audit logs are evidentiary โ€” they tell you what your users did, in a form you can trust. GDPR Article 30 requires organizations to maintain a record of processing activities, and audit trails are the technical implementation of that requirement.

This guide covers the full implementation: an immutable audit log table, middleware that captures every state change, querying the audit trail, and the GDPR considerations that determine what you log and how long you keep it.


What Belongs in an Audit Log

Not everything. Logging too much is a problem โ€” collecting too much information in logs can violate GDPR principles. Logs themselves become repositories of personal data and require the same protections as primary datasets. Excessive logging increases the attack surface and complicates compliance efforts.

Log the events that answer: "If something went wrong, could I reconstruct exactly what happened and who was responsible?"

Log these:

  • Authentication events: login, logout, failed login, password change, MFA changes

  • Permission changes: role assignments, access grants/revocations

  • Data exports: any bulk export of user or customer data

  • Destructive actions: delete, archive, purge

  • Financial events: payment attempts, refunds, plan changes

  • Admin actions: any action taken by an admin on behalf of another user

  • Sensitive data access: viewing PII, medical records, financial data

Do not log these:

  • Read operations on non-sensitive data (viewing a product listing)

  • Internal system events (cache misses, background job progress)

  • Raw personal data in the log payload โ€” use IDs and hashed identifiers


The Audit Log Schema

The audit log table must be append-only. No updates, no deletes โ€” including from your own application.

CREATE TABLE audit_logs (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),

  -- Who did it
  actor_id     UUID,           -- NULL for system/anonymous actions
  actor_email  TEXT,           -- Denormalized โ€” survives user deletion
  actor_role   TEXT,
  actor_ip     INET,

  -- What they did
  action       TEXT NOT NULL,  -- 'user.login', 'payment.refunded', 'role.changed'
  resource     TEXT,           -- 'user', 'order', 'subscription'
  resource_id  TEXT,           -- The affected record ID

  -- Tenant context
  tenant_id    UUID,

  -- The change
  old_value    JSONB,          -- State before the action
  new_value    JSONB,          -- State after the action
  metadata     JSONB,          -- Request context, extra fields

  -- When
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Indexes for common query patterns
CREATE INDEX idx_audit_actor_id      ON audit_logs(actor_id);
CREATE INDEX idx_audit_tenant_id     ON audit_logs(tenant_id);
CREATE INDEX idx_audit_resource      ON audit_logs(resource, resource_id);
CREATE INDEX idx_audit_action        ON audit_logs(action);
CREATE INDEX idx_audit_created_at    ON audit_logs(created_at DESC);

-- Prevent updates and deletes โ€” audit logs are immutable
CREATE RULE audit_logs_no_update AS ON UPDATE TO audit_logs DO INSTEAD NOTHING;
CREATE RULE audit_logs_no_delete AS ON DELETE TO audit_logs DO INSTEAD NOTHING;

The old_value and new_value columns capture the state before and after a change โ€” critical for reconstructing what happened. Denormalizing actor_email means the audit trail survives if the user account is later deleted.

The CREATE RULE statements are database-level enforcement. Even if application code has a bug that tries to update or delete an audit record, the database prevents it.


The Audit Logger

// src/lib/auditLogger.ts
import db from './db';

interface AuditEvent {
  actorId?:    string;
  actorEmail?: string;
  actorRole?:  string;
  actorIp?:    string;
  action:      string;   // 'user.created', 'role.changed', 'payment.refunded'
  resource?:   string;
  resourceId?: string;
  tenantId?:   string;
  oldValue?:   unknown;
  newValue?:   unknown;
  metadata?:   Record<string, unknown>;
}

export async function audit(event: AuditEvent): Promise<void> {
  try {
    await db.query(`
      INSERT INTO audit_logs (
        actor_id, actor_email, actor_role, actor_ip,
        action, resource, resource_id,
        tenant_id, old_value, new_value, metadata
      ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
    `, [
      event.actorId    || null,
      event.actorEmail || null,
      event.actorRole  || null,
      event.actorIp    || null,
      event.action,
      event.resource   || null,
      event.resourceId || null,
      event.tenantId   || null,
      event.oldValue   ? JSON.stringify(event.oldValue)  : null,
      event.newValue   ? JSON.stringify(event.newValue)  : null,
      event.metadata   ? JSON.stringify(event.metadata)  : null,
    ]);
  } catch (err) {
    // Audit log failures must not break the main operation
    // But they should be visible โ€” log the failure loudly
    logger.error({
      error:  (err as Error).message,
      action: event.action,
    }, 'AUDIT LOG WRITE FAILED');
  }
}

Structured Action Names

Use dot-notation action names that are consistent and queryable:

// src/lib/auditActions.ts
export const AuditActions = {
  // Auth
  AUTH_LOGIN:           'auth.login',
  AUTH_LOGIN_FAILED:    'auth.login.failed',
  AUTH_LOGOUT:          'auth.logout',
  AUTH_PASSWORD_CHANGED:'auth.password.changed',
  AUTH_MFA_ENABLED:     'auth.mfa.enabled',

  // Users
  USER_CREATED:         'user.created',
  USER_UPDATED:         'user.updated',
  USER_DELETED:         'user.deleted',
  USER_ROLE_CHANGED:    'user.role.changed',
  USER_INVITED:         'user.invited',

  // Data
  DATA_EXPORTED:        'data.exported',
  DATA_DELETED:         'data.deleted',

  // Billing
  SUBSCRIPTION_CREATED: 'subscription.created',
  SUBSCRIPTION_CANCELLED:'subscription.cancelled',
  PAYMENT_REFUNDED:     'payment.refunded',

  // Admin
  ADMIN_IMPERSONATED:   'admin.impersonated',
  ADMIN_CONFIG_CHANGED: 'admin.config.changed',
} as const;

export type AuditAction = typeof AuditActions[keyof typeof AuditActions];

Using the Audit Logger in Route Handlers

// src/routes/users.ts
import { audit, AuditActions } from '../lib/auditLogger';

router.patch('/users/:id/role', authenticate, requireRole('admin'), async (req, res) => {
  const { id } = req.params;
  const { role } = req.body;

  const existing = await getUserById(id, req.tenant.id);
  if (!existing) return res.status(404).json({ error: 'User not found' });

  const updated = await updateUserRole(id, role, req.tenant.id);

  // Audit the role change with before/after state
  await audit({
    actorId:    req.user.id,
    actorEmail: req.user.email,
    actorRole:  req.user.role,
    actorIp:    req.ip,
    action:     AuditActions.USER_ROLE_CHANGED,
    resource:   'user',
    resourceId: id,
    tenantId:   req.tenant.id,
    oldValue:   { role: existing.role },
    newValue:   { role },
    metadata: {
      requestId: req.id,
      userAgent: req.headers['user-agent'],
    },
  });

  res.json(updated);
});

// Auth events โ€” login and failed login
router.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await findUserByEmail(email);

  if (!user || !(await verifyPassword(password, user.passwordHash))) {
    // Log failed attempts โ€” useful for detecting brute force
    await audit({
      actorIp: req.ip,
      action:  AuditActions.AUTH_LOGIN_FAILED,
      metadata: {
        email,          // Email attempted โ€” not a real user field
        requestId: req.id,
        userAgent: req.headers['user-agent'],
      },
    });
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const tokens = generateTokens(user);

  await audit({
    actorId:    user.id,
    actorEmail: user.email,
    actorRole:  user.role,
    actorIp:    req.ip,
    tenantId:   user.tenantId,
    action:     AuditActions.AUTH_LOGIN,
    metadata: {
      requestId: req.id,
      userAgent: req.headers['user-agent'],
    },
  });

  res.json(tokens);
});

Querying the Audit Trail

// src/routes/admin/audit.ts

// Get audit trail for a specific resource
router.get('/admin/audit/:resource/:id', authenticate, requireRole('admin'), async (req, res) => {
  const { resource, id } = req.params;
  const limit  = parseInt(req.query.limit as string) || 50;
  const cursor = req.query.cursor as string | undefined;

  const result = await db.query(`
    SELECT
      id, actor_id, actor_email, actor_role, actor_ip,
      action, resource, resource_id,
      old_value, new_value, metadata,
      created_at
    FROM audit_logs
    WHERE
      resource    = $1
      AND resource_id = $2
      AND tenant_id   = $3
      ${cursor ? 'AND created_at < $4' : ''}
    ORDER BY created_at DESC
    LIMIT ${cursor ? '$5' : '$4'}
  `, cursor
    ? [resource, id, req.tenant.id, cursor, limit + 1]
    : [resource, id, req.tenant.id, limit + 1]
  );

  const rows = result.rows;
  const hasMore = rows.length > limit;
  if (hasMore) rows.pop();

  res.json({
    data:     rows,
    hasMore,
    nextCursor: hasMore ? rows[rows.length - 1].created_at : null,
  });
});

// Activity for a specific user โ€” for "session history" or DSAR requests
router.get('/admin/audit/actor/:userId', authenticate, requireRole('admin'), async (req, res) => {
  const result = await db.query(`
    SELECT action, resource, resource_id, metadata, created_at
    FROM audit_logs
    WHERE actor_id  = $1
      AND tenant_id = $2
    ORDER BY created_at DESC
    LIMIT 100
  `, [req.params.userId, req.tenant.id]);

  res.json(result.rows);
});

GDPR Considerations

Logs must have defined retention periods. Exceeding that timeframe without reason, even accidentally, constitutes a breach of the regulation.

Retention policy:

-- Automated cleanup โ€” run as a scheduled job
-- Retain audit logs for 2 years (adjust to your regulatory requirement)
DELETE FROM audit_logs
WHERE created_at < NOW() - INTERVAL '2 years';

Data minimisation in log payloads:

Avoid logging raw personal data such as full names, addresses, phone numbers, or full data records. Where necessary, replace them with pseudonymous identifiers or hashed values.

// BAD โ€” full PII in audit log
await audit({
  action:   AuditActions.USER_UPDATED,
  newValue: {
    name:    'Jane Smith',
    email:   '[email protected]',
    address: '123 Main St, London',
    dob:     '1985-03-15',
  },
});

// GOOD โ€” reference IDs, not PII
await audit({
  action:     AuditActions.USER_UPDATED,
  resource:   'user',
  resourceId: user.id,
  oldValue:   { fieldsChanged: ['email', 'address'] },  // What changed
  newValue:   { fieldsChanged: ['email', 'address'] },  // Not the values
});

DSAR (Data Subject Access Request) support:

Under GDPR, users can request all data you hold about them including audit logs that reference them.

// Generate DSAR package for a user โ€” all audit records referencing their ID
async function generateDSARReport(userId: string, tenantId: string) {
  const result = await db.query(`
    SELECT action, resource, resource_id, created_at, actor_ip
    FROM audit_logs
    WHERE (actor_id = $1 OR resource_id = $1)
      AND tenant_id = $2
    ORDER BY created_at DESC
  `, [userId, tenantId]);

  return {
    userId,
    generatedAt: new Date(),
    auditTrail:  result.rows,
  };
}

The Compliance Checklist

โœ… Audit table is append-only โ€” DB rules prevent UPDATE and DELETE
โœ… Actor email is denormalized โ€” audit trail survives account deletion
โœ… Action names are structured and consistent (dot notation)
โœ… Old and new values captured for state-change events
โœ… Audit failures logged loudly but don't break main operations
โœ… Retention policy defined and automated โ€” default 2 years
โœ… PII not stored in audit payloads โ€” IDs and field names only
โœ… DSAR query ready โ€” can export all records for a given user ID
โœ… Failed authentication attempts logged โ€” brute force detection
โœ… Admin impersonation logged โ€” who accessed whose account

Comments (0)

Login to post a comment.

ZyVOP
ZyVOP

Founder of Zyvop ๐Ÿš€ | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter

More from ZyVOP

View profile

Debian Adopts "Responsible Use of Generative AI" After Nine-Way Condorcet Vote

Debian's General Resolution 2026-002 closed on August 28 with "Responsible Use of Generative AI" beating eight rival proposals, including a Social Contract ban, by a clear Condorcet margin, per the project secretary's published beat matrix.

3 minAug 30

How I Built a Real-Time Developer Trend Radar Into My SEO Growth Engine

An AI-powered content intelligence system that streams live developer conversations from Hacker News, Dev.to, Google Search, and GitHub โ€” and turns them into ready-to-write blog opportunities with one click.

12 minAug 29

Qwen3.8-Flash-Next Cost Efficiency, OpenExecutive Satire, and Multi-Vector Retrieval Advances

This week's digest covers Qwen3.8-Flash-Next's push for ultimate cost-efficiency, the viral OpenExecutive project, and the technical release of MultiVectorEncoder in Sentence-Transformers v6.0.

4 minAug 28

Anthropicโ€™s Pricing Shock, Granite 4.2 Openโ€‘Source Leap, and AIโ€‘Powered Security & Policy Shifts

From Anthropicโ€™s flagship model losing steam to IBMโ€™s 512โ€ฏKโ€‘token Graniteโ€ฏ4.2, plus a new wave of AIโ€‘driven security exploits and policy alarms, this weekโ€™s digest maps the technical and market forces you need to act on now.

3 minAug 26

Introducing Questions and Discussions: A New Way to Connect!

We are thrilled to announce a major update to how you can interact and share content on our platform! Up until now, sharing your thoughts meant writing a standa...

2 minAug 1