A stolen table should not hand over passwords
Password hashing means storing a one-way scrambled copy of each password instead of the password itself. It matters because a stolen table of plain passwords opens every account at once, and people reuse passwords on other sites.
A session is the other half. After sign-in, the site gives the browser a token to show on every later request. A token that can be guessed, or never ends, works like a stolen password.
By the end of this lesson
Note
I can store passwords and check sessions safely in code.
Key words
| Word | What it means |
|---|---|
| Hash | a fixed-size scramble of data that cannot be turned back |
| Salt | random text added to a password before it is hashed |
| Slow hash | a hash made slow on purpose, to hinder guessing |
| Session token | a random value that stands for a signed-in visitor |
| Idle timeout | ending a session after a quiet spell |
| Absolute lifetime | the longest a session may last, however busy |
The cells students fill in are left blank in this preview.
Store a salted slow hash, never the password
A hash turns a password into a fixed-size scramble that cannot be reversed. The site stores the hash. At sign-in it hashes what was typed and compares the two.
A salt is random text added before hashing, different for every user. Two users with the same password get different hashes. A slow hash takes a fraction of a second on purpose, so guessing millions of passwords takes far too long.
Worked example: what a thief can do
| How the password is stored | What the thief sees | Guesses the thief can try |
|---|---|---|
| Plain text | Every password | None needed |
| Fast hash, no salt | Same hash for same password | Billions a second, all users at once |
| Fast hash with a salt | A different hash for each user | Billions a second, one user at a time |
| Slow salted hash | A different hash for each user | A few thousand a second, one user at a time |
The cells students fill in are left blank in this preview.
The same password, hashed twice
import hashlib
import secrets
# Change password to "tulip2" and press Run. Equal passwords still give different hashes.
password = "tulip"
salt_one = secrets.token_bytes(16)
salt_two = secrets.token_bytes(16)
first = hashlib.pbkdf2_hmac("sha256", password.encode(), salt_one, 100000)
second = hashlib.pbkdf2_hmac("sha256", password.encode(), salt_two, 100000)
again = hashlib.pbkdf2_hmac("sha256", password.encode(), salt_one, 100000)
print(len(first), len(second))
print("different salts:", first == second)
print("same salt:", first == again)
A common mistake
Warning
Many people think hashing a password with one fast function is enough. It is not. A fast hash lets an attacker try billions of guesses a second. Use a slow, salted password hash such as scrypt, bcrypt or Argon2, from a trusted library.
See that a salt changes the whole hash
The next two lines hash two invented words with a fast hash, so you can see the effect. The second word is the first with three characters added at the front, standing in for a salt. Compare the first characters of the two answers.
A hash of the word tulip
printf 'tulip' | sha256sumShown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
The same word with a salt in front
printf 'q7Ktulip' | sha256sumShown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
How the shop adds its users
docker exec app grep -n "INSERT INTO users" /tmp/app/app.pyShown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
What the three answers tell you
The first hash starts 6a6b4652 and the second starts 56b6ba29. Three added characters changed every part of the answer. That is why a salt defeats a table of ready-made hashes.
The third prints line 20 of the shop's program. It inserts users into a column named password, and the values are the passwords as typed. Ask what a stolen copy of that table would give an attacker.
A session token must be random and secret
A session token is what the browser shows on every request after sign-in. Anyone holding it is treated as that visitor.
So it must be long, random and made by a secure generator, never from a counter or the clock. The server keeps the real session, with the user and the times. The cookie carries only the token.
Worked example: flags on the session cookie
| Flag | What it does | What it helps stop |
|---|---|---|
| HttpOnly | Hides the cookie from page scripts | A script stealing the token |
| Secure | Sends the cookie only over an encrypted link | Someone on the network reading it |
| SameSite | Withholds the cookie on requests from other sites | A hostile page acting as the visitor |
The cells students fill in are left blank in this preview.
How long is a random token?
import secrets
# Change 16 to 32 and press Run. The token gets longer.
token = secrets.token_urlsafe(16)
print(len(token))
print(token == secrets.token_urlsafe(16))
Sessions must end
A session left open for ever is a spare key left in the door. Give it two limits. An idle timeout ends it after a quiet spell. An absolute lifetime ends it after a set time, however busy.
Signing out must destroy the session on the server, not only in the browser. Give a new token at sign-in so an old one cannot be reused.
Worked example: three sessions at the same moment
| Started | Last seen | Now | Result |
|---|---|---|---|
| 09:00 | 09:20 | 09:40 | Fine: 20 minutes idle, limit is 30 |
| 09:00 | 09:10 | 09:50 | Idle: 40 minutes quiet |
| 09:00 | 16:50 | 17:10 | Too old: 8 hours 10 minutes, limit is 8 hours |
The cells students fill in are left blank in this preview.
Judging a session with two limits
IDLE = 30
LIFETIME = 480
def state(created, last_seen, now):
if now - created > LIFETIME:
return "too old"
if now - last_seen > IDLE:
return "idle"
return "ok"
# Change 50 to 40 and press Run. Then try 500.
print(state(0, 10, 50))