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_SECRETwith 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 orAPP_SECRET. - Only
ip_hashis 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:
const ip = c.req.header('cf-connecting-ip');
const ipHash = await hmacSha256Hex(ipHashSecret, ip ?? 'unknown');hmacSha256Hex() uses the Web Crypto API that Workers provide:
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:
// 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_SECRETstable. If you change it, orIP_HASH_SECRETwhen 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.