Dev.to Security 🔐 Cybersecurity 👁 0 📖 5 min read

Flask JWT Authentication: Build a Secure Banking API in Python (Login, Roles, Tested)

Most JWT tutorials stop at "here's your token". But a token alone doesn't protect anything. The real security bugs happen one step later, when a logged-in customer can read someone else's bank account by changing an ID.

Most JWT tutorials stop at "here's your token". But a token alone doesn't protect anything. The real security bugs happen one step later, when a logged-in customer can read someone else's bank account by changing an ID.

In this tutorial we build a small banking REST API with Flask, Flask-JWT-Extended and SQLite, and we test every success and failure path:

  • secure password hashing
  • login that returns a JWT access token
  • protected routes with a Bearer token
  • customer data isolation: you only see your own accounts
  • an admin-only endpoint
  • Postman tests plus 7 automated tests

Project page: https://github.com/rainyreaksmey/python-jwt-authentication-api

The code below is simplified to show each idea clearly. The video builds every line on screen.

The endpoints

  • GET /api/health: public
  • POST /api/auth/login: public
  • GET /api/me: JWT
  • GET /api/accounts: JWT
  • GET /api/transactions: JWT
  • GET /api/admin/users: admin JWT

1. Setup and configuration

python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt   # Flask, Flask-JWT-Extended, python-dotenv
cp .env.example .env

The .env file holds a long JWT secret, a 30-minute token lifetime, and the SQLite path. Never commit a real production secret to Git.

2. The database: never store passwords, never store money as floats

Three tables:

  • users: unique username, password hash, full name, and a role limited to customer or admin
  • accounts: each belongs to one user through owner_id
  • transactions: each belongs to one account

Two design choices matter here:

  1. The password itself is never saved, only its hash.
  2. Money is stored as integer cents. 0.1 + 0.2 is not 0.3 in floating point, and you don't want that in a bank balance.

Every query uses parameterized SQL (? placeholders), so values never get mixed into the query text. That's what stops SQL injection.

3. Password hashing with Werkzeug

from werkzeug.security import generate_password_hash, check_password_hash

hash_ = generate_password_hash("RainyDemo123!")     # stored in the database
check_password_hash(hash_, submitted_password)      # True or False at login

That's the whole password flow. Seeding stores only the hash. Login compares safely with check_password_hash. We never decrypt or return a stored password, because a hash can't be decrypted.

4. The login endpoint

@app.post("/api/auth/login")
def login():
    data = request.get_json(silent=True) or {}
    username = str(data.get("username", "")).strip()
    password = data.get("password", "")
    if not username or not password:
        return {"error": "username and password are required"}, 400

    user = db().execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
    if user is None or not check_password_hash(user["password_hash"], password):
        return {"error": "invalid_credentials", "message": "Invalid username or password"}, 401

    token = create_access_token(identity=str(user["id"]), additional_claims={"role": user["role"]})
    return {"access_token": token, "token_type": "Bearer", "expires_in": 1800, "user": safe(user)}

Notice the security detail: a wrong username and a wrong password return the exact same response. If they returned different errors, an attacker could find out which usernames exist.

The token's identity is the user ID, and the role goes in as a claim. The response never includes the password hash.

5. Protected routes

@app.get("/api/me")
@jwt_required()
def me():
    user_id = int(get_jwt_identity())
    return {"user": safe(load_user(user_id))}

@jwt_required() rejects missing, malformed or expired tokens before your function runs. The client sends the token as a header:

Authorization: Bearer <access_token>

6. Authorization: the part most tutorials skip

Being logged in doesn't mean you can see everything. For accounts, admins keep the base query, but customers get an extra condition:

query = "SELECT * FROM accounts"
params = []
if user["role"] != "admin":
    query += " WHERE owner_id = ?"
    params.append(user["id"])

The authorization happens inside the database query, on the server. So the customer Rainy can't get Sokha's accounts by changing anything in the client. The same owner condition is applied to transactions, combined with an optional account_number filter and a limit that must be between 1 and 100, newest first.

The admin endpoint checks the stored role:

if user["role"] != "admin":
    return {"error": "forbidden", "message": "Administrator access required"}, 403

7. Real test results (Postman)

Every request below was run on screen against the local server on port 5000:

  • GET /api/health, no token → 200, status: ok
  • Login with the wrong password → 401 invalid credentials, no token, and nothing reveals whether the username exists
  • Login with the correct password → 200, a signed token, Bearer, 1,800-second lifetime, and Rainy's customer profile
  • GET /api/me with the token → 200, Rainy with the customer role
  • GET /api/accounts → 200, only Rainy's everyday and savings accounts. Sokha's account is excluded, even though it's in the same database.
  • GET /api/transactions?limit=… → 200, only Rainy's transactions
  • GET /api/accounts with no token → 401, and the protected data is never queried
  • GET /api/admin/users with Rainy's valid customer token → 403. The token is valid, but the role isn't.
  • Admin login, then GET /api/admin/users → 200, all three users, still with no password hashes

That 401 vs 403 difference is the whole lesson:

  • 401 Unauthorized: we don't know who you are (no token, or a bad one).
  • 403 Forbidden: we know exactly who you are, and you're not allowed.

8. Automated tests

python -m unittest discover -s tests -v

All 7 tests pass: health check, successful login, wrong-password rejection, missing-token rejection, customer data isolation, customer forbidden access, and admin access. The tests use a temporary SQLite database and a separate JWT secret, so they never touch your tutorial data.

Before you ship this

  • Replace the JWT secret and every demo credential.
  • Use HTTPS.
  • Keep access tokens short-lived, and add refresh-token revocation when you need it.
  • Run behind a production server, not Flask's development server.
  • Check authorization for every protected resource on the server, not only in the UI.

Recap

  • Hash passwords (Werkzeug) and store money as integer cents.
  • Use parameterized SQL everywhere.
  • Login returns the same error for a bad username or a bad password.
  • @jwt_required() handles authentication.
  • Owner conditions in your SQL and role checks handle authorization.
  • Test the failure paths, not just the happy path.

Full video with every request tested in Postman: https://youtu.be/qkIx4dWxm5w

Using FastAPI instead? I built the same idea in one 118-line file here: https://dev.to/rainytechlab/fastapi-jwt-authentication-in-2026-a-complete-login-api-in-one-file-argon2-pyjwt-tested-1ii6

Drafted with AI assistance. The code and numbers come from the recorded run. I make AI, coding and tech tutorials on RainyTech.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.