Bloom implements modern security best practices for authentication and session management. This document explains the security mechanisms and design decisions.
Bloom uses Argon2id instead of bcrypt for password hashing. Argon2id won the 2015 Password Hashing Competition and provides superior security against modern attack vectors.
Why Argon2id over bcrypt?
| Feature | bcrypt | Argon2id |
|---|---|---|
| Year | 1999 | 2015 |
| Memory-hard | No (~4 KB) | Yes (19 MB configurable) |
| GPU resistance | Moderate | Strong |
| ASIC resistance | Weak | Strong |
| Side-channel resistance | Moderate | Strong |
| Configurable | Work factor only | Memory + Time + Parallelism |
Bloom's Argon2id Configuration:
const ARGON2_CONFIG = {
type: argon2.argon2id, // Hybrid mode (best of argon2i + argon2d)
memoryCost: 19 * 1024, // 19 MB RAM per hash
timeCost: 2, // 2 iterations
parallelism: 1, // 1 thread
hashLength: 32, // 32-byte output (256 bits)
};Attack Resistance:
- Memory-hard: Requires 19 MB RAM per hash attempt, making GPU/ASIC attacks ~4,750x more expensive than bcrypt
- Tunable: Can increase memory/time cost as hardware improves
- Side-channel resistant: Protected against cache-timing attacks
Performance Impact:
On modern hardware, Argon2id with these settings can hash ~10,000 passwords per second on a GPU, compared to ~100,000 for bcrypt. This 10x slowdown for attackers provides significant security improvement while remaining fast enough for legitimate authentication (< 100ms per hash).
Every password receives a unique random salt to prevent rainbow table attacks and ensure identical passwords produce different hashes.
How it works:
// Registration - Generate new salt
const salt = randomBytes(32); // 32 bytes (256 bits) of entropy
const hash = await argon2.hash(password, { salt });
// Store both in database
UserCredentials.create({
password_hash: hash,
salt: salt.toString('base64'),
});// Login - Use existing salt
const credentials = await UserCredentials.findOne({ user_id });
const saltBuffer = Buffer.from(credentials.salt, 'base64');
const newHash = await argon2.hash(password, { salt: saltBuffer });
// Constant-time comparison
return timingSafeEqual(Buffer.from(credentials.password_hash), Buffer.from(newHash));Key points:
- Salt is generated with
crypto.randomBytes()(cryptographically secure) - Each user gets a unique salt, even with identical passwords
- Salt is stored in plaintext alongside the hash (not a secret)
- Salt never changes unless the password changes
- Same password + same salt = same hash (deterministic)
Why this works:
Without salt, two users with password "hello" would have identical hashes. An attacker could precompute hashes for common passwords (rainbow tables) and crack many accounts at once.
With salt, two users with password "hello" have different hashes because different salts are used. Rainbow tables become useless because the attacker needs a different table for every possible salt value (2^256 possibilities).
Bloom implements multi-layer password validation to prevent dictionary attacks while maintaining good UX.
Validation Layers:
-
Complexity Requirements (Zod schema validation)
- 8-256 characters
- At least one lowercase letter
- At least one uppercase letter
- At least one number
- At least one special character
-
Common Password Blacklist (80+ entries)
- Rejects common passwords that pass complexity requirements
- Examples:
Password123!,Welcome123!,Admin@123,Qwerty123! - Case-insensitive matching
- See
packages/core/src/utils/common-passwords.ts
-
Entropy Validation (50+ bits required)
- Calculates password randomness based on character set and length
- Formula:
entropy = length × log2(charset_size) - Charset size: 26 (lowercase) + 26 (uppercase) + 10 (digits) + 32 (special chars) = 94
- Example: 12-char password with all character types = ~79 bits entropy ✅
- Example: 8-char password with predictable pattern = ~40 bits entropy ❌
Error Messages:
Bloom provides clear, actionable error messages:
- "Password must be at least 8 characters"
- "This password is too common and appears in breach databases. Please choose a more unique password."
- "Password is not random enough. Try using a longer password with more varied characters."
Bloom uses cookie-based sessions with server-side storage in Redis or MongoDB.
Session Architecture:
// Generate session
const sessionId = randomBytes(32).toString('hex'); // 256 bits entropy
// Store in database
Session.create({
session_id: sessionId,
user_id: user._id,
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
ip_address: ctx.request.ip,
user_agent: ctx.request.userAgent,
browser: deviceInfo.browser,
os: deviceInfo.os,
device_type: deviceInfo.deviceType,
});
// Set cookie
response.cookies.set('bloom.sid', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
});Features:
- Instant revocation: Sessions can be deleted from Redis/MongoDB for immediate logout
- Multi-session management: Users can view all active sessions and revoke specific devices
- Compact cookies: Only session ID is transmitted (32 bytes)
- Sliding window expiration: Sessions can be extended on user activity
- Device tracking: Stores IP address, browser, OS, and device type per session
All tokens in Bloom use cryptographically secure random generation.
Token Types:
| Token Type | Entropy | Format | Lifetime |
|---|---|---|---|
| Session ID | 256 bits | Hex (64 chars) | 7 days |
| Email verification | 120 bits | Base32 (24 chars) | 24 hours |
| Password reset | 120 bits | Base32 (24 chars) | 1 hour |
Generation:
// Session ID - 256 bits
export function generateSessionId(): string {
const bytes = randomBytes(32); // 32 bytes = 256 bits
return bytes.toString('hex'); // 64 hex characters
}
// Verification/reset tokens - 120 bits
export function generateSecureToken(): string {
const bytes = randomBytes(15); // 15 bytes = 120 bits
return encodeBase32(bytes); // 24 Base32 characters
}Token Storage:
Tokens are hashed before storage to prevent database leaks:
// Hash token with SHA-256
const tokenHash = createHash('sha256').update(token).digest('hex');
// Store hash, not plaintext token
Token.create({
token_hash: tokenHash,
type: 'email_verification',
user_id: user._id,
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
// Verification compares hashes
const submitted = hashToken(submittedToken);
const stored = await Token.findOne({ token_hash: submitted });Why hash tokens?
If an attacker gains read access to the database, they cannot use the tokens because only the hash is stored. The plaintext token is only known by the legitimate user (sent via email).
Bloom implements IP-based rate limiting to prevent brute force attacks.
Default Limits:
| Endpoint | Max Requests | Window | Consequence |
|---|---|---|---|
| Login | 10 | 60 seconds | 429 Too Many Requests |
| Registration | 10 | 60 seconds | 429 Too Many Requests |
| Password Reset | 10 | 60 seconds | 429 Too Many Requests |
| Email Verification | 10 | 60 seconds | 429 Too Many Requests |
Implementation:
// Redis key: rateLimit:emailVerification:192.168.1.1
const key = `rateLimit:${action}:${ipAddress}`;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, window / 1000);
}
if (count > max) {
return new APIError(APIErrorCode.RATE_LIMITED).toResponse();
}Account Locking:
After multiple failed login attempts, accounts are temporarily locked:
// Increment on failed login
if (!isValidPassword) {
await credentials.incrementLoginAttempts();
// After 5 attempts, account locked for 15 minutes
}
// Reset on successful login
await credentials.resetLoginAttempts();Why IP-based?
- User-based: Allows distributed attacks (create many accounts, attack from each)
- IP-based: Limits attacks from single source, even across multiple accounts
Limitations:
- Users behind shared IPs (corporate networks, VPNs) may hit limits faster
- Attackers can use distributed IPs to bypass
- Trade-off between security and UX
Even with Argon2id hashing, weak passwords are vulnerable to dictionary attacks.
Bloom's Defense Layers:
-
Common Password Blacklist
- 80+ common passwords that meet complexity requirements
- Updated based on real-world breach data
- Examples:
Password123!,Welcome123!,Admin@123,Letmein1!
-
Entropy Validation
- Calculates randomness:
entropy = length × log2(charset_size) - Requires minimum 50 bits of entropy
- Prevents predictable patterns like
Abc12345!
- Calculates randomness:
-
Rate Limiting
- Even if password is weak, rate limiting slows attacks
- 10 attempts per 60 seconds = max 14,400 attempts/day per IP
Attack Scenarios:
| Password | Complexity | Blacklist | Entropy | Result |
|---|---|---|---|---|
password |
Fail | N/A | N/A | Rejected: complexity |
Password123! |
Pass | Blacklisted | N/A | Rejected: common |
Abc12345! |
Pass | Pass | ~40 bits | Rejected: low entropy |
kR7$mP9nX#2wQ5vL |
Pass | Pass | ~104 bits | Accepted |
Bloom uses constant-time comparison to prevent timing attacks during password verification.
Vulnerable Code:
// DON'T DO THIS
if (hash === storedHash) {
return true;
}This returns faster when hashes differ at the beginning vs the end. An attacker can measure response times to guess the hash character by character.
Secure Code:
// Constant-time comparison
return timingSafeEqual(Buffer.from(hash), Buffer.from(storedHash));This always takes the same time regardless of where strings differ, preventing timing attacks.
