Discriminator Normalizer
Phirewall provides two layers of key normalization to ensure consistent counting, prevent bypass attacks, and keep cache keys safe across all storage backends.
Overview
When a request is evaluated, the key goes through two normalization stages:
- Discriminator Normalizer (optional, user-configured): transforms the raw key before it reaches the cache key generator. Use this for domain-specific normalization like case-insensitive matching.
- Cache Key Generator (automatic): rule names are sanitized to safe characters; user-extracted keys are SHA-256 hashed for collision-free, fixed-length cache keys.
The Bypass Problem
Without normalization, attackers can bypass rate limiting by manipulating the key used for counting:
phirewall.throttle.api.<hash of "192.168.1.100"> ← Real IP
phirewall.throttle.api.<hash of "192.168.1.100 "> ← Trailing space
phirewall.throttle.api.<hash of " 192.168.1.100"> ← Leading spaceEach of these would produce a different SHA-256 hash and create a separate counter, effectively multiplying the attacker's rate limit. The discriminator normalizer prevents this by transforming keys before hashing.
Setting a Discriminator Normalizer
Use Config::setDiscriminatorNormalizer() to apply a transformation to all discriminator keys (throttle, fail2ban, allow2ban, track) before they are used for cache lookups:
use Flowd\Phirewall\Config;
use Flowd\Phirewall\Store\InMemoryCache;
$config = new Config(new InMemoryCache());
// Case-insensitive key matching
$config->setDiscriminatorNormalizer(fn(string $key): string => strtolower(trim($key)));The normalizer is a Closure that receives a string and returns a string. It is applied to every discriminator key extracted from requests before the cache key is generated.
Parameters
| Method | Signature | Description |
|---|---|---|
setDiscriminatorNormalizer() | Closure(string): string | Set the normalizer for all keys |
getDiscriminatorNormalizer() | returns ?Closure | Get the current normalizer (null if none set) |
How Cache Keys Are Generated
Phirewall's CacheKeyGenerator produces cache keys in this format:
{prefix}.{type}.{normalized_rule_name}.{hashed_key}Rule Name Normalization
Rule names are sanitized for safe use in cache keys:
- Trimmed - leading and trailing whitespace removed
- Sanitized - only
A-Za-z0-9._-characters are kept; all others replaced with_ - Deduplicated - consecutive underscores collapsed to one
- Truncated - names longer than 120 characters are shortened with a SHA-1 suffix
- Empty-safe - empty strings are replaced with
empty
Rule names are memoized internally for performance.
User Key Hashing
User-extracted keys (IP addresses, usernames, API keys, etc.) are hashed with SHA-256:
192.168.1.100 → a17c...e4f2 (64-character hex string)This ensures:
- Fixed length - regardless of input length, the cache key is always the same size
- No special characters - hex output is always safe for any cache backend
- Collision-free - SHA-256 has negligible collision probability
- No memory leak - unlike memoized normalization, hashing is stateless and safe for long-running processes
Examples
| Rule Name | Normalized |
|---|---|
ip-limit | ip-limit |
my rule with spaces | my_rule_with_spaces |
| (empty string) | empty |
| (very long name) | first_107_chars...-a1b2c3d4e5f6 |
| User Key | Cache Key Suffix |
|---|---|
192.168.1.100 | a17c9a... (SHA-256 hex) |
user@example.com | b4c3d2... (SHA-256 hex) |
| (500-char User-Agent) | f1e2d3... (SHA-256 hex) |
Common Normalizer Patterns
Case-Insensitive Matching
The most common use case: ensure that Admin and admin hit the same counter.
$config->setDiscriminatorNormalizer(fn(string $key): string => strtolower($key));Trim and Lowercase
Prevent whitespace and case variations from creating separate counters:
$config->setDiscriminatorNormalizer(
fn(string $key): string => strtolower(trim($key))
);Email Normalization
When rate limiting by email address (e.g., for password reset endpoints), normalize emails in the key extractor before they reach the discriminator normalizer:
$config->throttles->add('password-reset',
limit: 3, period: 3600,
key: function ($req): ?string {
if ($req->getUri()->getPath() !== '/api/password-reset') {
return null;
}
$body = (array) $req->getParsedBody();
$email = $body['email'] ?? null;
if ($email === null) return null;
// Normalize email before using as key
$email = strtolower(trim($email));
// Remove Gmail dots and plus addressing
if (str_ends_with($email, '@gmail.com')) {
[$local, $domain] = explode('@', $email, 2);
$local = str_replace('.', '', $local);
$local = explode('+', $local, 2)[0];
$email = $local . '@' . $domain;
}
return 'email:' . $email;
}
);Without this normalization, an attacker could bypass password reset limits using:
user@gmail.comu.s.e.r@gmail.comuser+tag1@gmail.comuser+tag2@gmail.com
All of these are the same Gmail inbox but would create different rate limit counters.
Why It Matters
Cache Backend Compatibility
Different cache backends have different key constraints:
| Backend | Key Limit | Unsafe Characters |
|---|---|---|
| Redis | 512 MB (practical: keep short) | None technically, but long keys waste memory |
| APCu | Varies by apc.shm_size | Control characters |
| PDO | 255 bytes (VARCHAR column) | Depends on collation |
| File-based | OS path limit (~260 chars) | /, \, NUL |
| Memcached | 250 bytes | Spaces, control characters |
SHA-256 hashing ensures user keys are always exactly 64 hex characters, safe across all backends. The Memcached and File-based rows are general PSR-16 examples; they are not bundled backends (Phirewall ships InMemoryCache, ApcuCache, RedisCache, and PdoCache).
Security
Without normalization, user-supplied values (like User-Agent strings, API keys, or email addresses) could:
- Enable bypass attacks - padding, encoding, or case variations create distinct keys for the same identity
- Exhaust cache memory - long user-agents or query strings create bloated keys
- Leak sensitive data - raw keys may appear in cache monitoring tools; hashed keys are opaque
Discriminator Normalizer vs. Key Extractor Normalization
The discriminator normalizer and key extractor serve different purposes:
| Concern | Where to Handle | Example |
|---|---|---|
| Global consistency (case, trim) | setDiscriminatorNormalizer() | strtolower() |
| Domain-specific logic | Key extractor closure | Email deduplication, username canonicalization |
| Cache safety | Automatic (CacheKeyGenerator) | SHA-256 hashing, rule name sanitization |
Apply the discriminator normalizer for concerns that apply to all rules. Use the key extractor for rule-specific logic.
Custom Key Prefixes
Use $config->setKeyPrefix() to change the prefix and avoid collisions when sharing a cache instance:
$config->setKeyPrefix('myapp');
// Keys become: myapp.throttle..., myapp.fail2ban..., etc.The prefix is validated: it is trimmed (whitespace and a trailing : stripped), must be non-empty, and may not contain a PSR-16 reserved character ({}()/\@:) or any control/whitespace character; otherwise setKeyPrefix() throws InvalidArgumentException at the call site.
Best Practices
Set a discriminator normalizer early. If you need case-insensitive matching, set the normalizer before adding rules. It applies globally to all rule types.
Normalize application-level keys yourself. The discriminator normalizer handles global concerns (case, trim). Domain-specific normalization (like email deduplication) should happen in your key closure.
Use consistent key structures. When writing custom key closures, prefix your keys to avoid collisions between different rule types:
user:123instead of just123.Avoid sensitive data in keys. While user keys are SHA-256 hashed in cache, the raw key is still visible in event payloads (
TrackHit,ThrottleExceeded, etc.). Use hashed or anonymized identifiers when possible.