Skip to main content
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.

Hash formats

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.