Fix ExpiredSignatureError: Signature has expired in Flask
This error occurs when a JWT token presented to your Flask app has passed its expiration time. It typically means the client is using a stale token or the server's expiry window is too short. Fix it by implementing a token refresh endpoint, extending the token lifetime, or catching the exception and returning a proper 401 response.
Reading the Stack Trace
Here's what each line means:
- File "/app/app/auth/decorators.py", line 22, in decorated_function: Your custom decorator calls jwt.decode which validates the token's expiration claim automatically.
- File "/app/venv/lib/python3.12/site-packages/jwt/api_jwt.py", line 281, in _validate_exp: PyJWT's internal validation compares the 'exp' claim to the current UTC time and finds the token is past its expiry.
- jwt.exceptions.ExpiredSignatureError: Signature has expired: The exception propagates up because the decorator has no try-except block to handle expired tokens gracefully.
Common Causes
1. Token expiry too short
The token was issued with a very short expiration window (e.g., 5 minutes) and the client did not refresh in time.
token = jwt.encode(
{'user_id': user.id, 'exp': datetime.utcnow() + timedelta(minutes=5)},
app.config['SECRET_KEY'],
algorithm='HS256'
)
2. No refresh token mechanism
The app issues a single access token with no way for the client to obtain a new one without re-authenticating.
@app.route('/login', methods=['POST'])
def login():
user = authenticate(request.json)
token = create_access_token(user)
return jsonify({'token': token}) # no refresh token
3. Unhandled exception in decorator
The decorator does not catch ExpiredSignatureError, causing a 500 error instead of a clean 401 response.
def token_required(f):
@wraps(f)
def decorated(*args, **kwargs):
token = request.headers.get('Authorization', '').replace('Bearer ', '')
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
# no try/except — crashes on expired token
return f(payload, *args, **kwargs)
return decorated
The Fix
Wrap jwt.decode in a try-except to catch ExpiredSignatureError and InvalidTokenError. Return a structured 401 JSON response with a 'TOKEN_EXPIRED' code so the client knows to call the refresh endpoint instead of crashing with a 500.
def token_required(f):
@wraps(f)
def decorated(*args, **kwargs):
token = request.headers.get('Authorization', '').replace('Bearer ', '')
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
return f(payload, *args, **kwargs)
return decorated
def token_required(f):
@wraps(f)
def decorated(*args, **kwargs):
token = request.headers.get('Authorization', '').replace('Bearer ', '')
if not token:
return jsonify({'error': 'Token is missing'}), 401
try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Token has expired', 'code': 'TOKEN_EXPIRED'}), 401
except jwt.InvalidTokenError:
return jsonify({'error': 'Token is invalid'}), 401
return f(payload, *args, **kwargs)
return decorated
Testing the Fix
import pytest
import jwt
from datetime import datetime, timedelta
from app import create_app
@pytest.fixture
def app():
app = create_app()
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'test-secret'
return app
def make_token(app, expired=False):
exp = datetime.utcnow() + (timedelta(seconds=-10) if expired else timedelta(hours=1))
return jwt.encode({'user_id': 1, 'exp': exp}, app.config['SECRET_KEY'], algorithm='HS256')
def test_expired_token_returns_401(app):
client = app.test_client()
token = make_token(app, expired=True)
resp = client.get('/api/profile', headers={'Authorization': f'Bearer {token}'})
assert resp.status_code == 401
assert resp.get_json()['code'] == 'TOKEN_EXPIRED'
def test_valid_token_succeeds(app):
client = app.test_client()
token = make_token(app, expired=False)
resp = client.get('/api/profile', headers={'Authorization': f'Bearer {token}'})
assert resp.status_code == 200
def test_missing_token_returns_401(app):
client = app.test_client()
resp = client.get('/api/profile')
assert resp.status_code == 401
Run your tests:
pytest tests/ -v
Pushing Through CI/CD
git checkout -b fix/flask-jwt-expired-token-handling,git add app/auth/decorators.py tests/test_auth.py,git commit -m "fix: handle expired JWT tokens with proper 401 response",git push origin fix/flask-jwt-expired-token-handling
Your CI config should look something like this:
name: CI
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install -r requirements.txt
- run: pytest tests/ -v --tb=short
- run: flake8 app/
The Full Manual Process: 18 Steps
Here's every step you just went through to fix this one bug:
- Notice the error alert or see it in your monitoring tool
- Open the error dashboard and read the stack trace
- Identify the file and line number from the stack trace
- Open your IDE and navigate to the file
- Read the surrounding code to understand context
- Reproduce the error locally
- Identify the root cause
- Write the fix
- Run the test suite locally
- Fix any failing tests
- Write new tests covering the edge case
- Run the full test suite again
- Create a new git branch
- Commit and push your changes
- Open a pull request
- Wait for code review
- Merge and deploy to production
- Monitor production to confirm the error is resolved
Total time: 30-60 minutes. For one bug.
Or Let bugstack Fix It in Under 2 minutes
Every step above? bugstack does it automatically.
Step 1: Install the SDK
pip install bugstack
Step 2: Initialize
import bugstack
bugstack.init(api_key=os.environ["BUGSTACK_API_KEY"])
Step 3: There is no step 3.
bugstack handles everything from here:
- Captures the stack trace and request context
- Pulls the relevant source files from your GitHub repo
- Analyzes the error and understands the code context
- Generates a minimal, verified fix
- Runs your existing test suite
- Pushes through your CI/CD pipeline
- Deploys to production (or opens a PR for review)
Time from error to fix deployed: Under 2 minutes.
Human involvement: zero.
Try bugstack Free →No credit card. 5-minute setup. Cancel anytime.
Deploying the Fix (Manual Path)
- Run pytest locally to verify all token scenarios return the correct status codes.
- Open a pull request with the decorator and test changes.
- Wait for CI checks to pass on the PR.
- Have a teammate review and approve the PR.
- Merge to main and verify expired tokens return 401 in staging.
Frequently Asked Questions
BugStack tests the fix with valid, expired, and malformed tokens, runs your full test suite, and confirms no authentication regressions before marking it safe to deploy.
BugStack never pushes directly to production. Every fix goes through a pull request with full CI checks, so your team can review it before merging.
A common pattern is 15-minute access tokens with 7-day refresh tokens. Adjust based on your security requirements and user experience needs.
Flask-JWT-Extended handles refresh tokens, blocklisting, and error responses out of the box. Use it for production apps to avoid reimplementing these patterns.