No raw IP addresses

How and why goodnight-web keeps only an HMAC of a visitor's IP address, never the address itself.

goodnight-web never stores or logs a visitor's IP address. It keeps only a keyed hash, HMAC-SHA256(ip, key), which is enough for rate limiting and for spotting repeat senders. The key is derived from APP_SECRET, or is IP_HASH_SECRET if you set that secret.

What it does

  • For each contact submission, the Worker computes ip_hash = HMAC-SHA256(ip, key).
  • The Worker derives the key from APP_SECRET with HKDF and a fixed label used only for IP hashes. The key that signs admin sessions uses another label, so neither key reveals the other or APP_SECRET.
  • Only ip_hash is stored in D1. The raw IP address is never written to D1 and never logged.
  • For its key, the Rate Limiting binding uses an HMAC made the same way. For an IPv6 address it hashes the /64 prefix instead of the full address.

In the code

The contact API reads the IP address from the cf-connecting-ip header and keeps only its hash. The raw IP address is sent only to Turnstile's siteverify API, as remoteip:

packages/server/src/features/contact/contact-routes.ts
const ip = c.req.header('cf-connecting-ip');
const ipHash = await hmacSha256Hex(ipHashSecret, ip ?? 'unknown');

hmacSha256Hex() uses the Web Crypto API that Workers provide:

packages/server/src/shared/crypto.ts
export const hmacSha256Hex = async (
  secret: string,
  message: string,
): Promise<string> => {
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );
  const signature = await crypto.subtle.sign(
    'HMAC',
    key,
    encoder.encode(message),
  );
  return [...new Uint8Array(signature)]
    .map((byte) => byte.toString(16).padStart(2, '0'))
    .join('');
};

The rate limit key is made the same way, with one difference: for an IPv6 address the HMAC covers only the first 64 bits (the /64 prefix). One IPv6 subscriber usually holds a whole /64, so counting single addresses would let one sender change addresses and never reach the limit. An IPv4 address (including one written in IPv6 form, ::ffff:192.0.2.1) is hashed in full. Setup and passkey sign-in use the same key, with a prefix (setup: or auth:), so each has its own counter:

packages/server/src/features/contact/contact-routes.ts
// 4. Rate limit per client: the IPv4 address, or the IPv6 /64 prefix,
//    hashed like ip_hash.
const { success } = await limiter.limit({
  key: await ipRateLimitKey(ipHashSecret, ip),
});
if (!success) return apiError(c, 429, 'rate_limited');

Why it is built this way

  • An IP address is personal data. It can identify a person, so storing it adds to the personal data you hold and must protect.
  • A hash is enough. The same IP always gives the same ip_hash, so you can still limit requests and see that several inquiries came from one sender.
  • A plain hash is not enough. A plain SHA-256 of an IP address is easy to reverse, because there are only about four billion IPv4 addresses to try. An HMAC with a secret key cannot be reversed without that key, and the key stays in the Worker.
  • Keep APP_SECRET stable. If you change it, or IP_HASH_SECRET when you set it yourself, new hashes no longer match old ones, so you can no longer see that a new inquiry came from the same sender as an older one.

On this page