Building a Secure Login System with Flask and JWT Authentication
A fundamental requirement for many web applications is a robust and secure login system. Traditional session-based authentication relies on server-side state, which can become complex to manage in distributed or microservice architectures. JSON Web Tokens (JWT) offer a stateless approach to authentication, providing scalability and flexibility. Combining the lightweight nature of the Flask web framework with JWT offers an efficient method for implementing secure user authentication.
Flask is a microframework for Python, known for its simplicity and extensibility. It provides the core functionalities needed to build web applications, leaving many decisions about databases, template engines, and authentication to the developer or extensions. This flexibility makes Flask suitable for building APIs and smaller applications where a full-stack framework might be overkill.
JWT (JSON Web Token) is an open standard (RFC 7519) for transmitting information securely between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs consist of three parts separated by dots: Header, Payload, and Signature. The Header typically contains the token type (JWT) and the signing algorithm (e.g., HMAC SHA256, RSA). The Payload contains claims, which are statements about an entity (typically the user) and additional data. Common claims include user ID, username, roles, and expiration time. The Signature is created by taking the encoded Header, the encoded Payload, a secret key, and the algorithm specified in the Header, and signing it. This signature is used to verify that the sender of the JWT is who it says it is and that the message hasn’t been tampered with.
Using JWT for authentication allows the server to issue a token upon successful login. This token is then sent by the client with subsequent requests to access protected resources. The server can verify the token’s validity using the signature and the shared secret key or public key, without needing to query a database for session information on every request.
Essential Concepts for Flask JWT Authentication
Understanding the core components and processes involved is crucial for building a secure Flask JWT system.
Flask Web Framework Basics
- Routing: Flask uses decorators (
@app.route('/path')) to map URLs to Python functions. These functions handle incoming requests and return responses. - Request Object: Flask provides a global
requestobject that contains incoming request data, such as form data, JSON payloads, query parameters, and headers. - Configuration: Application settings, like secret keys or database URIs, are stored in the
app.configdictionary.
JSON Web Tokens (JWT) Explained
- Structure: Header (metadata), Payload (claims), Signature (verification).
- Claims: Standard claims (like
expfor expiration,subfor subject/user ID) and custom claims (application-specific data). - Statelessness: The server does not need to store session state for authenticated users. The token itself contains the necessary information. This is beneficial for scaling horizontally.
- Verification: The server uses the secret key (for symmetric encryption like HS256) or public key (for asymmetric encryption like RS256) to verify the token’s signature. A valid signature ensures the token hasn’t been altered since it was issued.
The Authentication Flow with JWT
- User Login: The user submits credentials (username, password) to the server.
- Server Verification: The server verifies the credentials against stored user data (e.g., checking the hashed password).
- Token Issuance: If credentials are valid, the server generates a JWT (typically an access token and often a refresh token) containing user information (like user ID) in the payload.
- Token Transmission: The server sends the JWTs back to the client.
- Client Storage: The client stores the tokens (e.g., in local storage, session storage, or HTTP-only cookies).
- Accessing Protected Resources: For subsequent requests to protected endpoints, the client includes the access token, usually in the
Authorizationheader as a Bearer token (Authorization: Bearer <token>). - Server Validation: The server receives the request, extracts the token, and verifies its signature and expiration time.
- Resource Access: If the token is valid, the server processes the request and returns the requested data. If invalid, it rejects the request (e.g., returns 401 Unauthorized).
- Token Refresh: When an access token expires, the client uses a refresh token (if issued) to request a new access token from a dedicated refresh endpoint. Refresh tokens typically have a longer lifespan than access tokens.
Security Considerations with JWT
While statelessness is a benefit, JWTs require careful handling to ensure security.
- Secret Key Management: The secret key used to sign tokens must be kept confidential on the server. Compromising this key allows attackers to forge tokens.
- HTTPS/SSL/TLS: Tokens should always be transmitted over encrypted connections (HTTPS) to prevent interception.
- Token Storage on Client: Storing tokens in browser local storage is susceptible to Cross-Site Scripting (XSS) attacks. HTTP-only cookies offer better protection against XSS but require CSRF protection. Storing tokens securely on mobile applications also requires specific considerations.
- Expiration Times: Tokens should have appropriate expiration times. Short-lived access tokens reduce the window of opportunity if a token is compromised.
- Token Revocation (Blacklisting): Although JWTs are stateless by design, implementing a mechanism to invalidate tokens before their natural expiration (e.g., upon logout or security breach) is crucial. This is often done by maintaining a blacklist of revoked tokens on the server side.
- Payload Data: Avoid putting sensitive information directly into the token payload, as it is only encoded, not encrypted. Information suitable for the payload includes non-sensitive user identifiers or roles needed for authorization decisions.
Step-by-Step: Building the Flask JWT System
This section outlines the process of setting up a basic Flask application with JWT authentication using the Flask-JWT-Extended extension.
1. Project Setup and Dependencies
Begin by creating a project directory and installing Flask and Flask-JWT-Extended.
mkdir flask_jwt_authcd flask_jwt_authpython -m venv venvsource venv/bin/activate # On Windows use `venv\Scripts\activate`pip install Flask Flask-JWT-Extended werkzeugFlask: The web framework.Flask-JWT-Extended: An extension that simplifies working with JWTs in Flask.werkzeug: Used here specifically for secure password hashing.
2. Basic Flask App Structure
Create a file (e.g., app.py) and set up a minimal Flask application.
from flask import Flask, request, jsonifyfrom flask_jwt_extended import create_access_token, jwt_required, JWTManager, get_jwt_identity, create_refresh_token, jwt_refresh_token_required, get_raw_jwtfrom werkzeug.security import generate_password_hash, check_password_hash
app = Flask(__name__)
# --- Configuration ---# Change this in a real app!app.config["JWT_SECRET_KEY"] = "your-super-secret-key-change-me"# Configure JWT expiration timesapp.config["JWT_ACCESS_TOKEN_EXPIRES"] = 3600 # 1 hourapp.config["JWT_REFRESH_TOKEN_EXPIRES"] = 2592000 # 30 days
jwt = JWTManager(app)
# --- User Data (In-Memory for Demonstration) ---# In a real app, this would be a databaseusers = {}# A simple blacklist set for token revocationblacklist = set()
@jwt.token_in_blacklist_loaderdef check_if_token_in_blacklist(decrypted_token): jti = decrypted_token['jti'] return jti in blacklist
# --- Routes ---
if __name__ == "__main__": app.run(debug=True)- Initializes the Flask app.
- Configures the
JWT_SECRET_KEY. This must be a strong, randomly generated key in a production environment. - Sets expiration times for access and refresh tokens.
- Initializes
JWTManager. - Includes a basic in-memory structure for user data and a set for token blacklisting.
- The
@jwt.token_in_blacklist_loaderfunction is registered withFlask-JWT-Extendedto check if a token’s unique identifier (jti) is in the blacklist.
3. User Model/Data and Password Hashing
For a secure system, passwords must never be stored in plain text. Use a strong hashing algorithm. werkzeug.security provides functions for this.
Modify the users structure and add functions for hashing and checking passwords.
# ... (previous code)
# In-memory user storage: username -> { 'password': hashed_password, 'id': user_id }users = {} # Example: {'testuser': {'password': 'hashed_password_string', 'id': 1}}user_id_counter = 1 # Simple ID generator for demonstration
def add_user(username, password): global user_id_counter if username in users: return False # User already exists hashed_password = generate_password_hash(password) users[username] = {'password': hashed_password, 'id': user_id_counter} user_id_counter += 1 return True
def authenticate_user(username, password): user_data = users.get(username) if user_data and check_password_hash(user_data['password'], password): return user_data # Return user data including ID return None # Authentication failed
# --- Blacklist for token revocation ---# (Already included in step 2)blacklist = set()
@jwt.token_in_blacklist_loaderdef check_if_token_in_blacklist(decrypted_token): jti = decrypted_token['jti'] return jti in blacklist
# --- Routes ---# ... (add routes below)generate_password_hash(): Creates a salted hash of the password.check_password_hash(): Verifies a password against a hash.
4. Registration Endpoint (/register)
This endpoint handles creating new users.
# ... (previous code)
@app.route('/register', methods=['POST'])def register(): data = request.get_json() username = data.get('username') password = data.get('password')
if not username or not password: return jsonify({"msg": "Missing username or password"}), 400
if add_user(username, password): return jsonify({"msg": "User registered successfully"}), 201 else: return jsonify({"msg": "Username already exists"}), 409- Accepts POST requests with JSON data containing
usernameandpassword. - Checks for missing data.
- Uses the
add_userfunction to create the user with a hashed password. - Returns appropriate success or error responses.
5. Login Endpoint (/login)
This endpoint authenticates the user and issues JWTs.
# ... (previous code)
@app.route('/login', methods=['POST'])def login(): data = request.get_json() username = data.get('username') password = data.get('password')
if not username or not password: return jsonify({"msg": "Missing username or password"}), 400
user_data = authenticate_user(username, password)
if user_data: # Create the tokens. We pass the user's ID as the subject. # The identity is later retrieved by get_jwt_identity() access_token = create_access_token(identity=user_data['id']) refresh_token = create_refresh_token(identity=user_data['id']) return jsonify(access_token=access_token, refresh_token=refresh_token) else: return jsonify({"msg": "Bad username or password"}), 401- Accepts POST requests with JSON credentials.
- Uses
authenticate_userto verify credentials. - If successful,
create_access_token()andcreate_refresh_token()are used to generate tokens. Theidentityparameter is used to store information (here, the user ID) that can be retrieved later from the token. - Returns the access and refresh tokens in the response body.
6. Protected Endpoint (/profile)
This endpoint requires a valid access token to access.
# ... (previous code)
@app.route('/profile')@jwt_required() # Decorator to protect the routedef profile(): # Access the identity of the current user with get_jwt_identity current_user_id = get_jwt_identity() # In a real app, you would fetch user details from the database using this ID # For this example, we just return the ID return jsonify({"message": f"Welcome user with ID: {current_user_id}"})- The
@jwt_required()decorator ensures that a valid access token must be present in the request’sAuthorization: Bearer <token>header. get_jwt_identity()retrieves the identity (user ID in this case) that was stored in the token payload during creation.
7. Token Refresh Endpoint (/refresh)
This endpoint allows exchanging a valid refresh token for a new access token.
# ... (previous code)
@app.route('/refresh', methods=['POST'])@jwt_refresh_token_required() # Requires a valid refresh tokendef refresh(): current_user_id = get_jwt_identity() new_access_token = create_access_token(identity=current_user_id) return jsonify(access_token=new_access_token)- The
@jwt_refresh_token_required()decorator ensures a valid refresh token is provided. get_jwt_identity()retrieves the user ID from the refresh token.- A new access token is created with the same identity.
8. Logout/Token Revocation (/logout)
This endpoint demonstrates how to invalidate a token. Flask-JWT-Extended supports blacklisting based on the token’s unique identifier (jti).
# ... (previous code)
@app.route('/logout', methods=['POST'])@jwt_required() # Requires a valid access token to initiate logoutdef logout(): jti = get_raw_jwt()['jti'] # Get the unique identifier of the current token blacklist.add(jti) # Add the jti to the blacklist return jsonify({"msg": "Successfully logged out"}), 200
@app.route('/logout2', methods=['POST'])@jwt_refresh_token_required() # Also allow revoking the refresh token separatelydef logout2(): jti = get_raw_jwt()['jti'] blacklist.add(jti) return jsonify({"msg": "Refresh token revoked"}), 200get_raw_jwt()retrieves the full decoded token payload.- The
jti(JWT ID) claim is extracted. This is a unique identifier automatically added byFlask-JWT-ExtendedwhenJWT_BLACKLIST_ENABLEDis set toTrue(which is the default when usingtoken_in_blacklist_loader). - The
jtiis added to theblacklistset. - The
@jwt.token_in_blacklist_loaderfunction (defined earlier) is automatically called byFlask-JWT-Extendedwhenever a token is received. If the token’sjtiis found in theblacklist, the request is rejected. - Note: In a production environment, the blacklist should persist (e.g., using a database or cache like Redis) rather than being an in-memory set that resets when the server restarts.
Real-World Application: Single Page Applications and Mobile Apps
The Flask JWT authentication pattern is particularly well-suited for modern application architectures where the frontend (a single-page application built with frameworks like React, Vue, or Angular, or a mobile application) is separate from the backend API.
In such a scenario:
- The frontend handles user interaction and sends authentication requests (login, register) to the Flask backend API.
- Upon successful login, the Flask API returns JWTs (access and refresh tokens) to the frontend.
- The frontend stores these tokens. For example, a SPA might store them in
localStorageorsessionStorage, while a mobile app uses secure storage mechanisms provided by the platform. - For subsequent API calls to protected resources, the frontend includes the access token in the
Authorization: Bearerheader of the HTTP request. - The Flask backend verifies the token using
Flask-JWT-Extended. If valid, it processes the request. - When the access token expires, the frontend detects this (e.g., by receiving a 401 Unauthorized response from a protected endpoint) and uses the refresh token to request a new access token from the
/refreshendpoint. - User logout involves the frontend deleting the locally stored tokens and sending a request to a logout endpoint on the backend to blacklist the tokens.
This architecture benefits from JWT’s statelessness on the server side, allowing the backend API to scale horizontally without worrying about shared session state.
Key Takeaways and Actionable Insights
Building a secure login system with Flask and JWT requires attention to detail and adherence to security best practices.
- Choose
Flask-JWT-Extended: This library significantly simplifies JWT integration with Flask by handling token creation, validation, and common workflows like refreshing and blacklisting. - Protect the Secret Key: The
JWT_SECRET_KEYis paramount. Use a strong, random value and never expose it. Store it securely (e.g., using environment variables). - Hash Passwords: Always hash passwords using a strong, modern algorithm like bcrypt or scrypt (provided by
werkzeug.security). Never store plain text passwords. - Use HTTPS: All communication involving tokens must occur over HTTPS to prevent man-in-the-middle attacks.
- Set Appropriate Expiration: Configure sensible lifetimes for access tokens (short, e.g., 15 mins to a few hours) and refresh tokens (longer, e.g., days or weeks).
- Implement Token Revocation: Use a blacklist mechanism (as provided by
Flask-JWT-Extended) to invalidate tokens upon logout or security events. Ensure the blacklist is persistent. - Secure Client-Side Storage: Be mindful of where tokens are stored on the client side. HTTP-only cookies for access tokens can mitigate XSS risks, but introduce CSRF considerations. Secure local storage options exist for mobile apps.
- Validate Input: Sanitize and validate all user inputs (usernames, passwords, etc.) to prevent injection attacks.
- Consider Rate Limiting: Implement rate limiting on login attempts to prevent brute-force attacks.
- Error Handling: Provide clear but non-verbose error messages to the client (e.g., “Bad username or password” instead of specifying if the username or password was incorrect).
Implementing these steps provides a solid foundation for a secure, scalable authentication system using Flask and JWT.