fpyx
v1.4.0
Published
Hardened cross-runtime request fingerprinting for anonymous rate limiting. Zero dependencies. Zero I/O. Zero crypto APIs. Works in Node.js 18+, Bun, Deno, Cloudflare Workers, edge runtimes, and modern browsers. Fast FNV-1a 64-bit hashing with trusted IP d
Maintainers
Readme
fpyx
Why
Rate limiting requires a key. Getting that key right is harder than it looks.
Every rate limiter boils down to one operation: incrementing a counter for a key. But if your key is wrong, your rate limiter does absolutely nothing useful.
The industry standard is..lazy. Most developers just grab req.ip or naively split headers.get('x-forwarded-for').split(',')[0], and test their rate limiting on 127.0.0.1 or a static IPv4 address. They never see IPv6 privacy extensions rotating addresses in real-time, nor see an attacker injecting x-forwarded-for: 1.2.3.4, unknown, _hidden into a proxy chain.
So they don't even know their rate limiter is functionally useless until they get targeted by someone who knows what they're doing.
When you do that in production, you get hit by three things:
- Proxies: Behind a load balancer,
req.ipis the proxy's IP. Every request lands in the same bucket. - Spoofing: Blindly reading
x-forwarded-forlets attackers inject arbitrary strings and rotate their "IP" on every request. - IPv6: Modern OS privacy extensions rotate IPv6 interface identifiers constantly. Rate limiting on a full
/128means attackers bypass your limits just by waiting.
Cloudflare handles DDoS, but it doesn't know your app's business logic. That logic lives in your app, and it needs a correct key.
fpyx gives you that key and that key only. It takes a request, extracts the correct IP from headers your infrastructure actually controls, handles the IPv6 edge cases, and returns a stable FNV-1a fast hash.
Where
Zero dependencies. Works everywhere JavaScript runs with Fetch-standard Request/Headers (Node 18+, Bun, Deno, Cloudflare Workers, Vercel Edge).
[!IMPORTANT] This is not for identity or authentication.
What
Deep dive.
Read the docs/why document, along with the docs/security (attack vectors in IP-based rate limiting) and how fpyx handles them.
Comparison
How this differs from express-rate-limit, rate-limiter-flexible, Upstash Rate Limit, etc? See the full docs/comparison document.
Install
npm install fpyx
# or
pnpm install fpyx
# or
bun add fpyxHow
The place fpyx earns its keep is pre-auth surfaces: login, signup, password reset, public endpoints, anything where a user hasn't proven who they are yet and IP is the only anchor you have.
For authenticated requests, pass actorId and fpyx uses it exclusively (hashed and scoped, more below), IP is ignored entirely.
You call one function. Pass the request. Get an object back. Hand the hash to whatever counting layer you use (Redis, Upstash, memory, etc).
Here is how you can use it with @upstash/ratelimit:
import { fingerprint } from 'fpyx';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '60 s'),
});
export async function POST(request: Request) {
const { hash, traits } = fingerprint(request, {
// 1. Primary Anchor: Use actorId if the user is logged in.
// This ignores IP entirely to prevent identity fragmentation.
actorId: session?.userId,
// 2. Trust: Only check headers YOUR infrastructure controls.
ipHeaders: ['cf-connecting-ip', 'x-forwarded-for'],
// 3. Partitioning: Separate buckets for different endpoints.
scope: 'api/v1/login'
});
// Security Check: If no actor and no valid IP could be found
if (!traits.actorId && !traits.ip) {
console.warn("Unresolved identity: Blocking or alerting recommended.");
// return new Response('Unauthorized', { status: 401 });
}
const { success } = await ratelimit.limit(hash);
if (!success) return new Response('Too Many Requests', { status: 429 });
// ... proceed with logic
}Extra entropy
If the defaults feel too rigid, extra is an escape hatch for anything that doesn't fit scope or actorId. You pass a string, the library folds it into the hash verbatim.
// Invalidate all buckets on a specific day
fingerprint(request, {
extra: `day:${new Date().toISOString().slice(0, 10)}`,
});
// Kill-switch: rotate everyone's bucket on deploy
fingerprint(request, {
extra: process.env.BUCKET_VERSION ?? 'v1',
});extra is trimmed and ignored if empty, so passing undefined or '' has no effect on the hash.
The Output
If the user is unauthenticated (relying on IP):
{
hash: 'c72a1d8e90b3f445', // The key. Hand this to your counter.
parts: [
'ip:2001:0db8:abcd:1200:0000:0000:0000:0000', // IPv6 masked to /56
'scope:auth/login'
],
traits: {
actorId: null,
ip: '2001:0db8:abcd:1200:0000:0000:0000:0000',
scope: 'auth/login',
extra: null
}
}If the user is authenticated (relying on actorId):
{
hash: 'a3f1c8e2b4d09571',
parts: ['actor:user_abc123', 'scope:auth/login'],
traits: {
actorId: 'user_abc123',
ip: null, // Ignored entirely
scope: 'auth/login',
extra: null
}
}If extra is provided:
{
hash: 'f3a1...',
parts: ['ip:203.0.113.10', 'scope:views', 'extra:Something'],
traits: {
actorId: null,
ip: '203.0.113.10',
scope: 'views',
extra: 'Something'
}
}API
fingerprint(source, options?)
| Option | Type | Default | Description |
|---|---|---|---|
| actorId | string \| null | null | Verified user, session, or API key. Takes full precedence over IP when present. |
| ipHeaders | string[] | see below | Ordered list of headers to check for client IP. |
| ipv6Subnet | number | 56 | Prefix length for IPv6 subnet masking. Integer 1-128. |
| scope | string \| null | null | Opaque string for key space partitioning. |
| extra | string \| null | null | Caller-controlled opaque string for additional entropy. You normalize it, the library just folds it in. |
| hashFn | HashFunction | fnv1a64Hex | Custom hash function. |
Default ipHeaders precedence: cf-connecting-ip, fastly-client-ip, fly-client-ip, true-client-ip, forwarded, x-forwarded-for, x-real-ip.
Returns { hash, parts, traits }.
| Field | Type | Description |
|---|---|---|
| hash | string | 16-character hex string. Hand this to your counter. |
| parts | string[] | The components that produced the hash, before joining and hashing. |
| traits | FingerprintTraits | The resolved identity: actorId, ip, scope, extra. |
fnv1a64Hex(data)
The default hash function. FNV-1a 64-bit, returns a 16-character zero-padded lowercase hex string. Not cryptographic. Fast.
You can override it.
License
MIT (c) @rccyx
