Skip to content

Hashing passwords and signing tokens

~25 min ยท prerequisite: Storing and changing rows with SQLite

What you will be able to do

  • Store a password as a salted hash with hashlib.scrypt, and check a login attempt against it.
  • Build a signed token with hmac, and reject a token that was changed or has expired.
  • Explain how this kind of token relates to a JWT, and what it does not protect.

The idea

Never store a password as typed. Store a hash instead: a fixed-length value that cannot be turned back into the password. To check a login, hash the attempt the same way and compare.

The same password always gives the same hash, so attackers can prepare tables of guesses. A salt is random data mixed in per user, so two users with the same password get different stored values. Store the salt next to the hash.

Fast hashes such as SHA-256 let an attacker try billions of guesses. hashlib.scrypt is deliberately slow and memory-hungry. Its parameters n, r, and p set the cost.

After login, the server often hands out a token that the client sends with later requests. The server adds a signature: an HMAC, a hash keyed with a secret only the server knows. When the token returns, the server recomputes the HMAC. A mismatch means the token was changed or was not made by the server.

The payload also stores an expiry time, exp, so an old token stops working.

Worked example

import base64
import hashlib
import hmac
import json
import secrets

N, R, P = 2**14, 8, 1  # scrypt cost parameters
SECRET = b"demo-secret-only-for-this-lesson"


def hash_password(password):
    salt = secrets.token_bytes(16)
    key = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=N, r=R, p=P, dklen=32)
    return salt.hex() + "$" + key.hex()


def verify_password(password, stored):
    salt_hex, key_hex = stored.split("$")
    key = hashlib.scrypt(password.encode("utf-8"), salt=bytes.fromhex(salt_hex), n=N, r=R, p=P, dklen=32)
    return hmac.compare_digest(key, bytes.fromhex(key_hex))


def b64_encode(data):
    return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")


def b64_decode(text):
    return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))


def make_token(payload):
    body = b64_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
    signature = hmac.new(SECRET, body.encode("ascii"), hashlib.sha256).digest()
    return body + "." + b64_encode(signature)


def read_token(token, now):
    body, signature = token.split(".")
    expected = hmac.new(SECRET, body.encode("ascii"), hashlib.sha256).digest()
    if not hmac.compare_digest(b64_decode(signature), expected):
        raise ValueError("bad signature")
    payload = json.loads(b64_decode(body))
    if payload["exp"] < now:
        raise ValueError("token expired")
    return payload


stored = hash_password("correct horse")
print(verify_password("correct horse", stored))
print(verify_password("wrong guess", stored))

token = make_token({"user": "ada", "exp": 1000})
print(read_token(token, now=500))

Line by line, this is what Python does:

  1. hash_password draws 16 random bytes from secrets as the salt.
  2. hashlib.scrypt takes bytes, so the password is encoded first. The 32-byte result is stored as hex text beside the salt.
  3. verify_password hashes the attempt with the stored salt. hmac.compare_digest compares in constant time, which avoids a timing leak.
  4. make_token writes compact JSON, encodes it as URL-safe base64, and appends an HMAC-SHA256 signature.
  5. read_token checks the signature before it trusts the payload, then rejects an expired exp.

The output is True, False, and then the payload.

Try it

The first block checks password hashing, and the second checks tokens.

Run this on your own computer: the in-browser runner does not include hashlib.scrypt. Save it as a .py file and run python3 file.py.

import hashlib
import hmac
import secrets

N, R, P = 2**14, 8, 1

def hash_password(password):
    salt = secrets.token_bytes(16)
    key = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=N, r=R, p=P, dklen=32)
    return salt.hex() + "$" + key.hex()

def verify_password(password, stored):
    salt_hex, key_hex = stored.split("$")
    key = hashlib.scrypt(password.encode("utf-8"), salt=bytes.fromhex(salt_hex), n=N, r=R, p=P, dklen=32)
    return hmac.compare_digest(key, bytes.fromhex(key_hex))

first = hash_password("hunter2")
second = hash_password("hunter2")
assert first != second  # different salts give different stored values
assert verify_password("hunter2", first)
assert verify_password("hunter2", second)
assert not verify_password("hunter3", first)
assert "hunter2" not in first  # the password itself is not stored
import base64
import hashlib
import hmac
import json

SECRET = b"demo-secret-only-for-this-lesson"

def b64_encode(data):
    return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")

def b64_decode(text):
    return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))

def make_token(payload):
    body = b64_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
    signature = hmac.new(SECRET, body.encode("ascii"), hashlib.sha256).digest()
    return body + "." + b64_encode(signature)

def read_token(token, now):
    body, signature = token.split(".")
    expected = hmac.new(SECRET, body.encode("ascii"), hashlib.sha256).digest()
    if not hmac.compare_digest(b64_decode(signature), expected):
        raise ValueError("bad signature")
    payload = json.loads(b64_decode(body))
    if payload["exp"] < now:
        raise ValueError("token expired")
    return payload

token = make_token({"user": "ada", "exp": 1000})
assert read_token(token, now=500) == {"user": "ada", "exp": 1000}

body, signature = token.split(".")
forged_body = b64_encode(json.dumps({"user": "admin", "exp": 9999}).encode("utf-8"))
try:
    read_token(forged_body + "." + signature, now=500)
    assert False, "a forged token must not be accepted"
except ValueError as error:
    assert str(error) == "bad signature"

try:
    read_token(token, now=2000)
    assert False, "an expired token must not be accepted"
except ValueError as error:
    assert str(error) == "token expired"

Common mistakes

  1. Passing str to a hash function. hashlib.scrypt("secret", ...) fails with:

    TypeError: a bytes-like object is required, not 'str'
    

    Encode the text first with .encode("utf-8").

  2. Decoding base64 without the padding. This lesson strips the = padding, so decoding the raw text raises:

    binascii.Error: Incorrect padding
    

    The b64_decode helper restores the padding first.

  3. Reading a payload before checking the signature. Then an attacker can choose any user. Check the signature first.

  4. Leaving out exp. A token without it makes payload["exp"] raise KeyError: 'exp'.

Practice

  1. Hash the password "abc" twice. Assert that the stored strings differ, and that both verify.
  2. Assert that verify_password returns False for a wrong password and for an empty string.
  3. Write is_expired(payload, now) that returns True when payload["exp"] < now. Assert it before and after the expiry.
  4. Make a token for {"user": "bo", "exp": 100}. Assert that read_token at now=50 returns the payload, and that now=200 raises ValueError.
  5. Stretch: write token_user(token, now) that returns the user name, or None if read_token raises ValueError. Assert it for a valid token, an expired token, and a token with its last character changed.
Solution
  1. python first = hash_password("abc") second = hash_password("abc") assert first != second assert verify_password("abc", first) and verify_password("abc", second)

  2. python stored = hash_password("abc") assert verify_password("abd", stored) is False assert verify_password("", stored) is False

  3. ```python def is_expired(payload, now): return payload["exp"] < now

assert is_expired({"exp": 100}, 150) is True assert is_expired({"exp": 100}, 50) is False ```

  1. python token = make_token({"user": "bo", "exp": 100}) assert read_token(token, now=50) == {"user": "bo", "exp": 100}

read_token(token, now=200) raises ValueError.

  1. ```python def token_user(token, now): try: return read_token(token, now)["user"] except ValueError: return None

token = make_token({"user": "bo", "exp": 100}) changed = token[:-1] + ("A" if token[-1] != "A" else "B") assert token_user(token, 50) == "bo" assert token_user(token, 200) is None assert token_user(changed, 50) is None ```

Recap

  • Store scrypt hashes with a random salt, never the password. Compare with hmac.compare_digest.
  • Check a token's HMAC before you read its payload, and check exp.
  • A JWT has the same shape, header.payload.signature, with HMAC-SHA256 for HS256. This lesson's token is not a standard JWT, so real projects should use a maintained JWT library.

Sources