Home / Courses / Security for Beginners / II — Hands-On Practice
II — Hands-On Practice
Article 3 of 8

Hands-On: Password Hashing & JWT Authentication

Closing off Broken Authentication with bcrypt password hashing, a register endpoint with an explicit response model, and JSON Web Tokens to prove identity without the server storing sessions.

July 10, 2026

We start closing off the first threat from Part 2: Broken Authentication. The goal for this article: task-tracker-api gets correct register and login endpoints — passwords are never stored raw, and after logging in, a user gets a token that proves their identity on every subsequent request.

Why You Can Never Store Raw Passwords (or Weakly Hashed Ones)

If a database leaks (and that happens more often than you'd think), plaintext-stored passwords are immediately usable by an attacker — not just for our app, but very likely for OTHER apps too, since a lot of people reuse the same password.

The fix: hashing — a one-way function that turns a password into a random string that's (practically) impossible to reverse back to its original form. But not every hash algorithm is suited for passwords. MD5 and SHA1, for example, are designed to be FAST — a property that's actually dangerous for passwords, since it makes it easy for an attacker to try millions of combinations per second (brute force). We use bcrypt, which is deliberately designed to be SLOW and comes with an automatic salt (a unique random value per password, so two users with the same password still get different hashes).

Hashing With bcrypt

import bcrypt


def hash_password(plain_password: str) -> str:
    password_bytes = plain_password.encode("utf-8")
    hashed = bcrypt.hashpw(password_bytes, bcrypt.gensalt())
    return hashed.decode("utf-8")


def verify_password(plain_password: str, hashed_password: str) -> bool:
    return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))

bcrypt.gensalt() generates a new random salt every time it's called — which is why hashing the SAME password twice produces different hashes each time, but checkpw can still verify it because the salt is stored inside the hash itself.

Our User model only stores the hash, never the original password:

class User(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    username: str = Field(unique=True, index=True, max_length=50)
    hashed_password: str
    created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

Try it directly against the real database after registering:

SELECT username, hashed_password FROM "user";
 username |                       hashed_password
----------+--------------------------------------------------------------
 alice    | $2b$12$xX0UP187jMo/9kgHvBaD9eLaBR7fvduzuFUXo2jCUrl.Kitgr7KIi

The $2b$12$ prefix indicates the bcrypt algorithm with a cost factor of 12 — this number determines how many rounds of computation are done: higher means slower (and harder to brute-force), but also slower for legitimate logins. 12 is a well-balanced default for most applications.

The Register Endpoint

@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
def register(payload: UserCreate, session: Session = Depends(get_session)):
    existing = session.exec(select(User).where(User.username == payload.username)).first()
    if existing:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username already taken")

    user = User(username=payload.username, hashed_password=hash_password(payload.password))
    session.add(user)
    session.commit()
    session.refresh(user)
    return user

Notice response_model=UserRead, not User directly — exactly the Excessive Data Exposure principle from Part 2. UserRead only has id, username, created_athashed_password NEVER makes it into the response, even though the user object returned from the database has that field. FastAPI automatically filters it out based on the schema.

JSON Web Tokens (JWT) — Verifiable Proof of Identity Without Storing Sessions

Once login succeeds, the server needs a way to tell the client "you're proven to be user X" — and the client needs a way to prove that on every subsequent request, without logging in again every time. A JWT is a token carrying data (called claims, e.g. "username: alice"), digitally signed by the server using a secret_key.

Its structure is three parts separated by dots: header.payload.signature. The important part to understand: the signature is generated from a combination of the token's contents and the secret_key, which only the server knows. If anyone tries to alter the payload (say, changing "sub": "alice" to "sub": "admin"), the signature no longer matches, and the server rejects it — WITHOUT needing to keep a list of valid tokens in a database or in memory.

import jwt
from datetime import datetime, timedelta, timezone


def create_access_token(subject: str) -> str:
    expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
    payload = {"sub": subject, "exp": expire}
    return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)


def decode_access_token(token: str) -> Optional[str]:
    try:
        payload = jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm])
    except jwt.PyJWTError:
        return None
    return payload.get("sub")

Two important details:

  • "exp" (expiration) — the token is automatically considered invalid after a certain time (we set 30 minutes). This limits the damage if the token ever leaks — unlike a password, which stays valid forever until changed manually.
  • algorithms=[settings.jwt_algorithm] on decode, never left open — this prevents a fairly well-known JWT security flaw, where an attacker tries to force the server to accept a token signed with a weaker algorithm, or even "none".
  • The Login Endpoint

    @router.post("/login", response_model=Token, dependencies=[Depends(rate_limit_login)])
    def login(
        form_data: OAuth2PasswordRequestForm = Depends(),
        session: Session = Depends(get_session),
    ):
        user = session.exec(select(User).where(User.username == form_data.username)).first()
    
        if not user or not verify_password(form_data.password, user.hashed_password):
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Incorrect username or password",
                headers={"WWW-Authenticate": "Bearer"},
            )
    
        access_token = create_access_token(subject=user.username)
        return Token(access_token=access_token)
    

    An easy-to-miss security detail: the error message "Incorrect username or password" is EXACTLY THE SAME whether the problem is "username not found" or "wrong password." If the messages differed ("User not found" vs. "Wrong password"), an attacker could use this API to guess which usernames are registered (called username enumeration) — just try a bunch of usernames and see whether the error message differs. dependencies=[Depends(rate_limit_login)] is covered in full in Part 4.

    Trying the Flow

    curl -X POST localhost:8000/auth/register -H "Content-Type: application/json" \
      -d '{"username":"alice","password":"supersecret123"}'
    # {"id":1,"username":"alice","created_at":"..."}
    
    curl -X POST localhost:8000/auth/login -d "username=alice&password=supersecret123"
    # {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type":"bearer"}
    

    Try decoding that token's payload (the middle part, between the two dots) via jwt.io or python -c "import base64; print(base64.b64decode('...'))" — you'll see it's just {"sub": "alice", "exp": 1783693476}, no password or any other sensitive data. A JWT can be read by anyone (it just can't be forged) — never put secret data in its payload.

    Summary

  • bcrypt for password hashing — deliberately slow, with an automatic salt, far more secure than MD5/SHA1.
  • The register endpoint uses an explicit response_model so hashed_password never gets exposed in the response.
  • A JWT proves identity via a signature signed with a secret_key, without the server needing to store sessions.
  • Always set a token expiration, and lock the algorithm accepted on decode.
  • Login error messages must be generic, identical for every failure case, so no valid-username information leaks.
  • Topics

    SecurityAuthenticationJWT