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
customeroradmin -
accounts: each belongs to one user through
owner_id - transactions: each belongs to one account
Two design choices matter here:
- The password itself is never saved, only its hash.
-
Money is stored as integer cents.
0.1 + 0.2is not0.3in 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/mewith 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/accountswith no token → 401, and the protected data is never queried -
GET /api/admin/userswith 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.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.