← Home

106. Hashlib Module

Cryptographic hashing with hashlib

106. Hashlib Module

Rohan saves a JSON report every 5 seconds. After a week, he has 100,000+ files. Many snapshots are identical — RAM barely changed between runs. He wants to skip saving a report if nothing changed.

He computes a content hash to detect duplicates:

import hashlib, json

def snapshot_hash(snapshot: dict) -> str:
    # Serialize to a canonical string, then hash it
    content = json.dumps(snapshot, sort_keys=True)
    return hashlib.sha256(content.encode()).hexdigest()

Two notes:

  • content.encode() — hashlib requires bytes, not a string. .encode() converts using UTF-8.
  • .hexdigest() — returns a 64-character hex string like "a3f2c1...". .digest() returns raw binary bytes — not useful for storing or comparing.

He stores the last hash and skips saving if unchanged:

last_hash = None

def maybe_save(snapshot, report_dir):
    global last_hash
    h = snapshot_hash(snapshot)
    if h == last_hash:
        return   # nothing changed
    last_hash = h
    # ... save the report

SHA-256 is a one-way function — you cannot reverse it. Two different inputs that produce the same hash (a “collision”) have never been found for SHA-256. This is why it’s used to store passwords: store the hash, never the password. On login, hash the attempt and compare.

In production, never hash passwords with SHA-256 alone. Use bcrypt or argon2 which are deliberately slow to resist brute-force. SHA-256 is for content fingerprinting, not password storage.

💡 Fun fact: SHA-256 is part of the SHA-2 family designed by the NSA and published in 2001. It produces a 256-bit (32-byte) digest — displayed as 64 hexadecimal characters. SHA-256 is used everywhere: Git uses it to identify commits and blobs, Bitcoin uses it twice to validate every transaction, and TLS certificates use it to sign web traffic.

⚠️ Watch out: .encode() without arguments defaults to UTF-8, which is almost always what you want. But if the string contains non-ASCII characters (e.g., accented letters, emojis), make sure both the hasher and the verifier use the same encoding. If you hash "café".encode("utf-8") but later compare against "café".encode("latin-1"), they produce different hashes and verification will silently fail.

🤔 Think about it: SHA-256 hashing is deterministic — same input always gives same output. But salt is added before hashing passwords. Why does adding a salt help against attackers even if they know the salt? What attack does salting prevent that hashing alone does not?

Learning objectives

  • Encode strings to bytes with .encode() before passing to hashlib
  • Use .hexdigest() to get a human-readable hex string (not .digest() for bytes)
  • Implement salted password hashing and verification
  • Understand that SHA-256 hex digests are always 64 characters

Key concepts

  • hashlib.sha256() — creates a hash object
  • .encode() — converts str to bytes
  • .hexdigest() — returns hex string output
  • .digest() — returns raw bytes output
  • Salt — string added to prevent rainbow table attacks

Try it

Concept detail

hashlib — Cryptographic Hashing

The hashlib module provides secure hash functions (SHA-256, SHA-512, MD5, etc.). Hashing is one-way: you can verify a hash, but cannot reverse it to get the input.

The bytes Requirement

All hashlib functions require bytes, not str. Always call .encode() first:

import hashlib

# Wrong — raises TypeError:
hashlib.sha256("hello")

# Correct:
hashlib.sha256("hello".encode())         # defaults to UTF-8
hashlib.sha256("hello".encode("utf-8"))  # explicit encoding

digest() vs hexdigest()

h = hashlib.sha256("hello".encode())
h.digest()     # b'\x2c\xf2...'  raw bytes (32 bytes for SHA-256)
h.hexdigest()  # '2cf24dba...'   hex string (64 chars for SHA-256)

Use .hexdigest() when you need to store or compare hashes as strings. Use .digest() only when you need raw bytes (rare — e.g., HMAC computations).

Salted Password Hashing Pattern

def hash_password(password: str, salt: str) -> str:
    combined = (password + salt).encode()
    return hashlib.sha256(combined).hexdigest()

stored = hash_password("myPassword", "random_salt_123")
# Later:
is_valid = hash_password("attempt", "random_salt_123") == stored

When NOT to Use SHA-256 for Passwords

SHA-256 is fast — attackers can test billions of passwords per second. For user passwords, use bcrypt or argon2 (deliberately slow). Use SHA-256 for: file checksums, content deduplication, API request signing.

Solution

import hashlib

def hash_password(password: str, salt: str = "pyforge_salt") -> str:
    """Hash a password with a salt and return the hex digest string."""
    combined = (password + salt).encode()  # encode to bytes first
    hash_obj = hashlib.sha256(combined)
    return hash_obj.hexdigest()  # hex string, not raw bytes

def verify_password(attempt: str, stored_hash: str, salt: str = "pyforge_salt") -> bool:
    """Return True if attempt hashes to the same value as stored_hash."""
    return hash_password(attempt, salt) == stored_hash

def is_strong_password(password: str) -> bool:
    """Return True if password is at least 8 characters."""
    return len(password) >= 8

Tests

KNOWN_PASSWORD = "securePass99"
KNOWN_SALT = "pyforge_salt"
# Pre-computed: sha256(("securePass99pyforge_salt").encode()).hexdigest()
KNOWN_HASH = hashlib.sha256((KNOWN_PASSWORD + KNOWN_SALT).encode()).hexdigest()

def test_hash_password_returns_string():
    result = hash_password(KNOWN_PASSWORD)
    assert isinstance(result, str), f"hash_password should return str, got {type(result)}"

def test_hash_password_not_bytes():
    result = hash_password(KNOWN_PASSWORD)
    assert not isinstance(result, bytes), "hash_password must return str not bytes (use .hexdigest())"

def test_hash_password_correct_length():
    result = hash_password(KNOWN_PASSWORD)
    assert len(result) == 64, f"SHA-256 hex digest should be 64 chars, got {len(result)}"

def test_hash_password_known_value():
    result = hash_password(KNOWN_PASSWORD, KNOWN_SALT)
    assert result == KNOWN_HASH, f"Hash mismatch for known input"

def test_hash_password_deterministic():
    result1 = hash_password("hello123")
    result2 = hash_password("hello123")
    assert result1 == result2, "Same input should always produce same hash"

def test_hash_password_different_inputs():
    h1 = hash_password("password1")
    h2 = hash_password("password2")
    assert h1 != h2, "Different passwords should produce different hashes"

def test_verify_password_correct():
    stored = hash_password(KNOWN_PASSWORD)
    assert verify_password(KNOWN_PASSWORD, stored) is True

def test_verify_password_wrong():
    stored = hash_password(KNOWN_PASSWORD)
    assert verify_password("wrongPassword", stored) is False

Resources