GDPR/Guides/IP Hashing · Node.js

How to Hash IP Addresses for GDPR Compliance in Node.js

Most analytics tools — including most QR code platforms — log the raw IP address of every visitor. Under GDPR, that's personal data. Here's the technique we use at Qrius to get useful scan analytics without storing a single IP address.

The problem with raw IP logging

GDPR Article 4(1) defines personal data as anything relating to an "identifiable natural person." The Court of Justice confirmed in Breyer v. Germany that IP addresses qualify — even dynamic ones — because ISPs can link them to a subscriber. If you're logging the IP of every QR scan, you're collecting personal data. That means you need a legal basis, purpose limitation, data retention policy, and potentially a DPA.

Most platforms just don't bother thinking about this. We did. The solution isn't to throw away all analytics — it's to extract what you need and immediately destroy the part that's personal.

SHA-256 + daily rotating salt

A plain sha256(ip) is not enough. IPv4 has about 4 billion addresses. Anyone with the hash can precompute a rainbow table and reverse it in seconds. Not anonymous.

Add a secret salt stored in an environment variable and you make reversal computationally infeasible — the attacker would need your secret. That gets you to pseudonymous. Still personal data under strict GDPR reading, but much better.

The daily rotation is the final step. We combine the secret salt with today's date in YYYY-MM-DD format. Same IP, different day, completely different hash. Cross-day correlation is impossible by construction — not just policy.

Why the rotating salt matters

Imagine you hash IPs with a static secret. If an attacker somehow obtains your hash database and your secret (a breach, a rogue employee, a court order), they can reconstruct which hashes belong to which IP addresses — and then track that IP across months of scan data.

With a daily salt, there's nothing to correlate. The hash for IP 87.123.45.67 on Tuesday is completely unrelated to the hash for the same IP on Wednesday. You can answer "how many unique devices scanned this QR today?" but you literally cannot answer "did the same person scan yesterday and today?" That's the point.

EDPB guidanceThe European Data Protection Board's Opinion 05/2014 on anonymisation techniques notes that hashing with a secret key can achieve anonymisation if re-identification is "not reasonably likely." Daily salt rotation strengthens that argument significantly by eliminating cross-day linkability entirely.

The implementation

This is the actual code from utils/crypto.ts in the Qrius backend. Copy it directly — there's nothing magic here, just Node's built-in crypto module.

// utils/crypto.ts
import crypto from 'crypto'

/**
 * Returns today's date as YYYY-MM-DD combined with the secret salt.
 * This changes every midnight UTC, making cross-day hash correlation impossible.
 */
export function getDailySalt(): string {
  const today = new Date().toISOString().split('T')[0] // e.g. "2025-06-01"
  return today + (process.env.IP_HASH_SALT ?? '')
}

/**
 * Hash an IP address with a daily rotating salt.
 * Returns a 16-character hex string — enough for uniqueness counts,
 * short enough that it's clearly not the original IP.
 */
export function hashIP(ip: string): string {
  const salt = getDailySalt()
  return crypto
    .createHash('sha256')
    .update(ip + salt)
    .digest('hex')
    .substring(0, 16)
}

Set IP_HASH_SALT in your environment to a long random string. Generate one with:

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Keep it in your secrets manager. If it leaks, rotate it immediately — all historical hashes become worthless to an attacker using the new salt, which is actually fine. The hashes are not meant to be decoded.

What you can and can't do with hashed IPs

You can

  • Count unique scans per day
  • Deduplicate multiple scans from the same device in one session
  • Know total unique devices this week (sum of daily uniques)
  • Correlate scans within a single UTC day

You can't

  • Track the same person across multiple days
  • Reverse the hash to get the original IP
  • Correlate scans across different QR codes for the same user
  • Build a user profile over time

That last column isn't a bug. It's the design. If you need to track individual users across sessions, you need consent — and that's a different system entirely.

Extract geo data before you hash

You need to run the geolocation lookup before hashing and discarding the IP. We use the geoip-lite npm package — it's a local lookup against a bundled MaxMind database, so there's no outbound network request and no data leaving your server.

import geoip from 'geoip-lite'
import { hashIP } from './utils/crypto'
import { parse as parseUA } from 'ua-parser-js'

export async function logScan(ip: string, userAgent: string, slug: string) {
  // 1. Geo lookup FIRST — while we still have the raw IP
  const geo = geoip.lookup(ip)
  const country = geo?.country ?? null   // e.g. "SE"
  const city = geo?.city ?? null         // e.g. "Stockholm"

  // 2. Hash the IP — the original is no longer needed
  const ipHash = hashIP(ip)

  // 3. Parse the user agent for device/browser/OS info
  const ua = parseUA(userAgent)
  const deviceType = ua.device.type ?? 'desktop'   // "mobile" | "tablet" | "desktop"
  const browserName = ua.browser.name ?? null       // "Chrome", "Safari", etc.
  const osName = ua.os.name ?? null                 // "iOS", "Android", "Windows", etc.

  // 4. Write to DB — no raw IP, no raw user agent string
  await prisma.qRScan.create({
    data: {
      qrCodeSlug: slug,
      ipHash,        // 16 hex chars
      country,       // derived before IP was discarded
      city,          // derived before IP was discarded
      deviceType,
      browserName,
      osName,
      referrer: null, // populate from request headers if needed
      scannedAt: new Date(),
    },
  })
}

Notice there's no userAgent column in the database. We parse it for signals, store those signals, and discard the raw string. User agent strings can be surprisingly identifying in combination with IP and time.

What to store — and only this

The scan record we write to the database has exactly nine fields. Nothing more.

FieldExample valuePersonal data?
ipHasha3f8c2d1e4b7f901No — irreversible
countrySENo — aggregated
cityStockholmNo — aggregated
deviceTypemobileNo
osNameiOSNo
browserNameSafariNo
referrerhttps://instagram.comNo
scannedAt2025-06-01T09:12:33ZNo — no user link
qrCodeSlugsummer-2025No

Not stored: raw IP, raw user agent string, precise geolocation (lat/lng), any user identifier, session ID, or fingerprint. The full redirect handler runs logScan as a fire-and-forget async call so it doesn't add latency to the 302 redirect.

You still need a retention policy

"We don't store personal data" doesn't mean "we can keep this forever." GDPR's storage limitation principle (Article 5(1)(e)) applies to all data, including genuinely anonymous data if it might be re-linkable in context. Even if your hashed scan records are truly anonymous today, a future technical development might change that assessment.

Define a retention period, document it in your privacy policy, and enforce it with a scheduled deletion job. We recommend 24 months for scan analytics — enough for year-over-year comparisons, not so long that you're hoarding data for no reason.

// Run this nightly — delete scan records older than 24 months
await prisma.qRScan.deleteMany({
  where: {
    scannedAt: {
      lt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 365 * 2),
    },
  },
})

The bigger picture

IP hashing is one piece of a broader compliance architecture. This guide covers the technical implementation. For the full picture — EU hosting, DPA, cookie-free tracking, and what analytics you can show without ever touching personal data — see our GDPR compliance overview.

If you'd rather use a platform where all of this is already handled for you, Qrius is built on exactly the code shown in this guide. Every scan goes through this pipeline before anything touches a database.

Use a platform that already does this

Qrius runs this exact hashing pipeline on every scan. No raw IPs. No cookies. EU-hosted in Stockholm. Free to start.