Accounts and logins
Password hashing with bcrypt, sessions, pages that need a login, and safe logout.
Create a free account Try part of it here
Why it matters
As soon as an app has accounts, it holds something valuable: people reuse passwords across sites, so a leaked password table hurts them far beyond your app. Login code is also where small slips become serious bugs, like a message that tells an attacker which emails have accounts, or a session that survives logout. The good news is that the safe way is short and well known. This lesson builds it in Flask, step by step, and explains why each step is there.
What you'll be able to do
- Store passwords as slow, salted bcrypt hashes and check them safely
- Explain what Flask's signed session cookie protects, and what it doesn't
- Build sign-up, login and logout, with pages that need a login
- Avoid the classic account mistakes: revealing which emails exist, session fixation and open redirects
What it covers
- Never store a password
- Sessions: remembering who's signed in
- Sign up, log in, log out
- Settings and limits worth knowing
- Worked example: the logged-in user on every request
Never store a password
An app doesn't need to know your password; it needs to check it. So instead of the password it stores a hash: the output of a one-way function. At login it hashes what you typed and compares. If the database leaks, the attacker gets hashes, not passwords.
Not every hash will do. SHA-256 is designed to be fast, and fast is bad here: a graphics card can try billions of guesses a second against a leaked SHA-256 hash. Password hashing functions such as bcrypt, scrypt and Argon2 are deliberately slow and tunable. bcrypt also adds a random salt to every hash, so two people with the same password get different hashes, and an attacker can't crack the whole table at once with a precomputed list.
import bcrypt password = "correct horse battery".encode() first = bcrypt.hashpw(password, bcrypt.gensalt(rounds=4)) second = bcrypt.hashpw(password, bcrypt.gensalt(rounds=4)) print(first) print(second) print("same hash twice?", first == second) print("right password:", bcrypt.checkpw(password, first)) print("wrong password:", bcrypt.checkpw(b"correct horse battery!", first))
The hash carries everything checkpw needs: $2b$ names the algorithm, 04 is the cost, and the next 22 characters are the salt. bcrypt works on bytes, so encode the text first; it also only reads the first 72 bytes of a password, which is plenty for real ones.
About rounds: each extra round doubles the work. These lessons use rounds=4 so the checks stay fast in your browser. Real apps use the default, 12, or more: slow enough to hurt an attacker making billions of guesses, fast enough that one login takes a fraction of a second.
import bcrypt h = bcrypt.hashpw(b"hunter2", bcrypt.gensalt(rounds=4)) print(bcrypt.checkpw(b"hunter2", h), h == bcrypt.hashpw(b"hunter2", bcrypt.gensalt(rounds=4)))
Show the answer
True False
checkpw reads the salt from the stored hash, so it matches: True. Hashing again uses a new random salt, so the two hashes differ: False.
Sessions: remembering who's signed in
HTTP has no memory: each request arrives on its own. After a successful login, the app needs a way to recognize the next request from the same browser. Flask's session does it with a cookie. You use session like a dictionary, and Flask stores its contents in a cookie signed with app.secret_key.
Signed is not secret. The person can read everything in the cookie; they just can't change it without breaking the signature. See for yourself:
import base64 from flask import Flask, session app = Flask(__name__) app.secret_key = "practice-only-key" @app.route("/login-as/<int:user_id>") def login_as(user_id): session["user_id"] = user_id return "ok" @app.route("/whoami") def whoami(): return f"user {session.get('user_id')}" client = app.test_client() client.get("/login-as/7") cookie = client.get_cookie("session").value payload, timestamp, signature = cookie.split(".") print("cookie:", cookie) print("anyone can read it:", base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))) print(client.get("/whoami").get_data(as_text=True)) forged = base64.urlsafe_b64encode(b'{"user_id":1}').decode().rstrip("=") client.set_cookie("session", f"{forged}.{timestamp}.{signature}") print("after tampering:", client.get("/whoami").get_data(as_text=True))
The forged cookie claims to be user 1, but its signature doesn't match, so Flask ignores it and the session comes back empty. Two rules follow. Keep secret_key long, random and out of your code (an environment variable), because anyone who has it can sign any cookie they like. And store only an identifier in the session, never a password, a hash or anything private.
Sign up, log in, log out
With hashing and sessions in place, the three views are short. The details are what make them safe:
- Sign up: validate the email and password, refuse an email that's already registered, store the bcrypt hash, then log the new user in.
- Log in: look the user up, check the password with
bcrypt.checkpw, and answer the same message whether the email is unknown or the password is wrong ("Wrong email or password."). Different messages would tell an attacker which emails have accounts. - Before logging someone in, clear the session with
session.clear(). Otherwise an attacker who planted a session cookie in the victim's browser (session fixation) would share the signed-in session. - Log out with a POST (a link someone else could trigger is a GET) and clear the session.
Pages that need a login check the session first and send visitors to the login page. A decorator, from the decorators lesson, keeps that check in one place:
from functools import wraps def login_required(view): @wraps(view) def wrapper(*args, **kwargs): if "user" not in session: return redirect(url_for("login")) return view(*args, **kwargs) return wrapper @app.route("/dashboard") @login_required def dashboard(): ...
@wraps keeps the view's name, which Flask uses as the endpoint for url_for. Without it, every protected view would be called "wrapper", and Flask would refuse to register a second one.
A common extra is /login?next=/settings, which returns people to the page they wanted. Only follow next if it's a path on your own site (it starts with a single /), or your login page becomes an open redirect that sends your users to a look-alike site after they sign in.
Most account bugs are small choices like these. Each safe choice costs a line or two.
Settings and limits worth knowing
Flask's defaults are good, and a few settings make sessions tighter in production:
SESSION_COOKIE_HTTPONLY = True(the default) hides the cookie from JavaScript, so an injected script can't read it.SESSION_COOKIE_SECURE = Truesends the cookie only over HTTPS. Turn it on wherever the site uses HTTPS, which should be everywhere.SESSION_COOKIE_SAMESITE = "Lax"keeps the cookie off most cross-site POSTs, a second layer against CSRF.PERMANENT_SESSION_LIFETIMElimits how long a "remember me" session lasts.
Login pages also need a limit on attempts per account and per address, or an attacker can simply try passwords all day; the reliability lesson shows how rate limiting works. Larger apps often hand all of this to a library such as Flask-Login, or to an identity provider with "Sign in with ..." buttons. Even then, knowing what happens underneath is what lets you configure them correctly and review their use.
Worked example: the logged-in user on every request
Many views need the current user. Flask's g object holds values for one request, and a before_request function runs before every view, so the user is loaded in one place.
from flask import Flask, session, g, redirect, url_for USERS = {"ada@example.com": {"name": "Ada", "role": "admin"}, "lin@example.com": {"name": "Lin", "role": "member"}} app = Flask(__name__) app.secret_key = "practice-only-key" @app.before_request def load_user(): g.user = USERS.get(session.get("user")) @app.route("/test-login/<email>") def test_login(email): session.clear() session["user"] = email return "ok" @app.route("/admin") def admin(): if g.user is None: return redirect(url_for("test_login", email="?")) if g.user["role"] != "admin": return "Admins only", 403 return f"Welcome to the admin page, {g.user['name']}" for who in [None, "lin@example.com", "ada@example.com"]: client = app.test_client() if who: client.get(f"/test-login/{who}") reply = client.get("/admin") print(who, "->", reply.status_code, reply.get_data(as_text=True)[:60])
load_user runs before every view and puts the user, or None, in g.user, so views never repeat the lookup. The admin view shows the difference between two status codes: a visitor who isn't signed in is sent to log in (a redirect), while a signed-in member who lacks the role gets 403 Forbidden: the app knows who they are, and the answer is no. That's authentication (who are you?) and authorization (what may you do?) side by side. The /test-login route exists only to make the example short; a real app would never have it.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why is bcrypt better than SHA-256 for storing passwords?