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:
hash_passworddraws 16 random bytes fromsecretsas the salt.hashlib.scrypttakes bytes, so the password is encoded first. The 32-byte result is stored as hex text beside the salt.verify_passwordhashes the attempt with the stored salt.hmac.compare_digestcompares in constant time, which avoids a timing leak.make_tokenwrites compact JSON, encodes it as URL-safe base64, and appends an HMAC-SHA256 signature.read_tokenchecks the signature before it trusts the payload, then rejects an expiredexp.
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¶
-
Passing
strto a hash function.hashlib.scrypt("secret", ...)fails with:Encode the text first with
.encode("utf-8"). -
Decoding base64 without the padding. This lesson strips the
=padding, so decoding the raw text raises:The
b64_decodehelper restores the padding first. -
Reading a payload before checking the signature. Then an attacker can choose any
user. Check the signature first. -
Leaving out
exp. A token without it makespayload["exp"]raiseKeyError: 'exp'.
Practice¶
- Hash the password
"abc"twice. Assert that the stored strings differ, and that both verify. - Assert that
verify_passwordreturnsFalsefor a wrong password and for an empty string. - Write
is_expired(payload, now)that returnsTruewhenpayload["exp"] < now. Assert it before and after the expiry. - Make a token for
{"user": "bo", "exp": 100}. Assert thatread_tokenatnow=50returns the payload, and thatnow=200raisesValueError. - Stretch: write
token_user(token, now)that returns the user name, orNoneifread_tokenraisesValueError. Assert it for a valid token, an expired token, and a token with its last character changed.
Solution
-
python first = hash_password("abc") second = hash_password("abc") assert first != second assert verify_password("abc", first) and verify_password("abc", second) -
python stored = hash_password("abc") assert verify_password("abd", stored) is False assert verify_password("", stored) is False -
```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 ```
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.
- ```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
scrypthashes with a random salt, never the password. Compare withhmac.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 forHS256. This lesson's token is not a standard JWT, so real projects should use a maintained JWT library.