# 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](https://goodnight-web.dev/docs/glossary.md#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`:

```ts title="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:

```ts title="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:

```ts title="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.
