How to Log GDPR Consent in Node.js (With Code Example)

en

If your app has users in the EU, clicking "Accept" on a cookie banner isn't the finish line — it's the starting point. Under GDPR, the burden of proof for consent sits with you, the data controller. If a regulator or a user ever asks "when did I agree to this, and to what exactly?", "we assume they clicked accept" is not going to hold up.

This is where a lot of solo developers and small SaaS teams get GDPR wrong. They ship a cookie banner, feel compliant, and never think about what happens six months later when an audit request lands in their inbox. A consent banner shows intent. A consent log proves it.

In this guide, we'll walk through what a proper GDPR consent record actually needs, and how to build a minimal but audit-ready version in Node.js.

What GDPR Actually Requires You to Prove

Article 7(1) of GDPR puts the accountability requirement plainly: if processing is based on consent, the controller must be able to demonstrate that the data subject consented. Not just have a checkbox somewhere in your database — demonstrate it, on request, potentially years later.

In practice, a defensible consent record needs:

  • Who consented (a user ID, session ID, or hashed identifier — never raw personal data you don't need)
  • What they consented to (the exact policy version or purpose, e.g. "analytics cookies v2")
  • When consent was given (a precise timestamp)
  • How consent was captured (the specific banner, form, or action)
  • Proof of the interface shown (ideally a snapshot of what the user actually saw, since banner copy changes over time)

Most teams store maybe two of these five. That gap is exactly what gets flagged in an audit.

Designing a Minimal Consent Log Schema

Before writing any code, define what a consent event actually looks like in your system. A simple table or document structure works fine — you don't need a dedicated compliance platform to get the basics right:

consent_id       uuid
user_id          string (or session id for anonymous users)
purpose          string   // e.g. "analytics", "marketing"
policy_version   string   // e.g. "privacy-policy-v3"
consent_given    boolean
ip_hash          string   // hashed, never raw IP
user_agent       string
timestamp        datetime

Notice ip_hash, not ip. Storing raw IP addresses for every consent event creates its own GDPR exposure — you're now processing personal data to prove you handled personal data correctly. Hash it with a salt so you retain the ability to detect duplicate or suspicious activity without keeping a reversible identifier.

Building It in Node.js

Here's a minimal Express endpoint that logs a consent event with the fields above:

javascript

import express from "express";
import crypto from "crypto";

const app = express();
app.use(express.json());

const IP_SALT = process.env.CONSENT_IP_SALT; // keep this secret, rotate carefully

function hashIp(ip) {
  return crypto
    .createHash("sha256")
    .update(ip + IP_SALT)
    .digest("hex");
}

app.post("/api/consent", async (req, res) => {
  const { userId, purpose, policyVersion, consentGiven } = req.body;

  if (!userId || !purpose || !policyVersion || typeof consentGiven !== "boolean") {
    return res.status(400).json({ error: "Missing required consent fields" });
  }

  const record = {
    consent_id: crypto.randomUUID(),
    user_id: userId,
    purpose,
    policy_version: policyVersion,
    consent_given: consentGiven,
    ip_hash: hashIp(req.ip),
    user_agent: req.headers["user-agent"] || "unknown",
    timestamp: new Date().toISOString(),
  };


  await saveConsentRecord(record); // your DB call here

  res.status(201).json({ success: true, consent_id: record.consent_id });
});

A few details worth calling out:

  • Validate before you log. An incomplete consent record is worse than no record at all, because it looks like you tried and failed to comply.
  • Log both grants and withdrawals. If a user later revokes consent, that's a separate event with its own timestamp — don't overwrite the original row.
  • Never mutate old records. Consent logs should be append-only. If policy version changes, that's a new consent event tied to the new version, not an edit to the old one.

Where This Usually Breaks in Practice

Two things tend to bite teams that build this themselves:

  1. No proof of what the banner actually looked like. If your cookie banner copy changes six months from now, and someone disputes what they agreed to, a database row with policy_version: "v3" is only as good as your ability to reproduce what v3 actually said and looked like at that moment. Screenshotting the banner state at the time of consent closes this gap.
  2. Export requests take longer than they should. When a user or auditor asks "show me every consent event for this person," you want a clean CSV in minutes, not a custom SQL query written under pressure.

If you're maintaining this yourself, it's worth budgeting time for both — a snapshot mechanism and an export path — not just the logging endpoint itself.

Keeping It Simple as You Scale

For a single form on a single site, the code above is genuinely enough. Where it gets harder is when you have multiple purposes (analytics, marketing, third-party sharing), multiple policy versions over time, and need to hand over records fast during an actual audit — that's usually the point where hand-rolled logging starts costing more engineering time than it saves.

If you'd rather not maintain this layer yourself, ConsentKeep handles consent logging as a lightweight API built specifically for solo developers and small SaaS teams — screenshot proof, IP hashing, and CSV export included, so the audit trail is already done before you need it.