When a user updates their password through a password input, FunnelFox sends hashed versions of that password in the profile.updated webhook payload.
{
"id": "evt_123456789",
"type": "profile.updated",
"data": {
"id": "pro_123456789",
"email": "[email protected]",
"phone_number": "+1234567890",
"password_hashes": {
"argon2id": "$argon2id$v=19$m=65536,t=1,p=4$...",
"bcrypt": "$2a$10$...",
"pbkdf2_sha256": "$pbkdf2-sha256$i=100000$...",
"scrypt": "$scrypt$n=32768,r=8,p=1$..."
}
}
}
Learn more in the webhook reference.
FunnelFox normalizes all passwords to UTF-8 NFC before hashing.
Password hashes are provided in four formats:
argon2id — modern, secure algorithm (PHC format)
bcrypt — widely supported (standard bcrypt format)
pbkdf2_sha256 — PBKDF2-HMAC-SHA256 (PHC format)
scrypt — memory-hard algorithm (PHC format)
Each hash contains all the parameters needed for verification. Salts are included in the hash strings, so you don’t need to store them separately.
Argon2ID
PHC format:
$argon2id$v=19$m=65536,t=3,p=4$<base64-salt>$<base64-hash>
Parameters:
- Memory: 65536 KiB (64 MB)
- Time cost: 3 iterations
- Parallelism: 4 threads
- Hash length: 32 bytes
- Salt length: 16 bytes
BCrypt
Standard format:
$2a$12$<22-char-salt><31-char-hash>
Parameters:
- Cost factor: 12
- Salt length: 16 bytes (encoded as 22 base64 characters)
PBKDF2-SHA256
PHC format:
$pbkdf2-sha256$i=210000$<base64-salt>$<base64-hash>
Parameters:
- Iterations: 210,000
- Hash algorithm: SHA-256
- Hash length: 32 bytes
- Salt length: 16 bytes
Scrypt
PHC format:
$scrypt$n=65536,r=8,p=1$<base64-salt>$<base64-hash>
Parameters:
- N (CPU/memory cost): 65536
- r (block size): 8
- p (parallelization): 1
- Hash length: 32 bytes
- Salt length: 16 bytes
Verification
To verify passwords in your system, compare the plaintext password against the hash received from the webhook.
Dependencies
pip install argon2-cffi bcrypt cryptography
Example
Always use constant-time comparison functions like hmac.compare_digest to compare hashes. This protects against timing attacks.
import base64
import hashlib
import hmac
import unicodedata
from argon2.low_level import Type, hash_secret_raw
import bcrypt
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
from cryptography.hazmat.primitives import hashes
def normalize_password(password):
"""Normalize password to UTF-8 NFC."""
return unicodedata.normalize('NFC', password)
def verify_argon2id(password, hash_string):
"""Verify Argon2ID hash."""
# Parse: $argon2id$v=19$m=65536,t=1,p=4$salt$hash
parts = hash_string.split('$')
# Extract parameters from the hash
params = {}
for param in parts[3].split(','):
key, value = param.split('=')
params[key] = int(value)
# Decode salt and hash
salt = base64.b64decode(parts[4] + '==')
expected_hash = base64.b64decode(parts[5] + '==')
# Compute hash
computed = hash_secret_raw(
secret=normalize_password(password).encode('utf-8'),
salt=salt,
time_cost=params['t'],
memory_cost=params['m'],
parallelism=params['p'],
hash_len=len(expected_hash),
type=Type.ID
)
return hmac.compare_digest(computed, expected_hash)
def verify_bcrypt(password, hash_string):
"""Verify BCrypt hash."""
return bcrypt.checkpw(
normalize_password(password).encode('utf-8'),
hash_string.encode('utf-8')
)
def verify_pbkdf2(password, hash_string):
"""Verify PBKDF2-SHA256 hash."""
# Parse: $pbkdf2-sha256$i=210000$salt$hash
parts = hash_string.split('$')
iterations = int(parts[2].split('=')[1])
salt = base64.b64decode(parts[3] + '==')
expected_hash = base64.b64decode(parts[4] + '==')
# Compute hash
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=len(expected_hash),
salt=salt,
iterations=iterations
)
computed = kdf.derive(normalize_password(password).encode('utf-8'))
return hmac.compare_digest(computed, expected_hash)
def verify_scrypt(password, hash_string):
"""Verify Scrypt hash."""
# Parse: $scrypt$n=32768,r=8,p=1$salt$hash
parts = hash_string.split('$')
# Extract parameters
params = {}
for param in parts[2].split(','):
key, value = param.split('=')
params[key] = int(value)
salt = base64.b64decode(parts[3] + '==')
expected_hash = base64.b64decode(parts[4] + '==')
# Compute hash
kdf = Scrypt(
salt=salt,
length=len(expected_hash),
n=params['n'],
r=params['r'],
p=params['p']
)
computed = kdf.derive(normalize_password(password).encode('utf-8'))
return hmac.compare_digest(computed, expected_hash)
# Usage
def verify_password(password, hash_string):
"""Verify password against any supported hash format."""
if hash_string.startswith('$argon2id$'):
return verify_argon2id(password, hash_string)
elif hash_string.startswith('$2a$') or hash_string.startswith('$2b$'):
return verify_bcrypt(password, hash_string)
elif hash_string.startswith('$pbkdf2-sha256$'):
return verify_pbkdf2(password, hash_string)
elif hash_string.startswith('$scrypt$'):
return verify_scrypt(password, hash_string)
else:
raise ValueError("Unknown hash format")
Troubleshooting
Some Base64 decoders require padding. If decoding fails, add == padding as shown in the example code above.