Flask JWT Authentication, Stripe Webhooks & CORS: The Complete Python SaaS Guide (2026)
You had a working idea. A real problem worth solving. You opened your laptop on a Monday with genuine excitement.
By Friday, you were debugging a JWT signature mismatch at 2AM. The following Monday, Stripe webhooks were failing in production but working perfectly locally. Two weeks later, CORS was blocking every request from your Next.js frontend and you didn't understand why.
The idea was still good. The excitement was gone.
This is the infrastructure trap — and it kills more SaaS projects than bad ideas ever will. This guide gives you the exact production-ready code to get through it fast. Flask JWT authentication, Stripe webhook verification, and CORS configuration — with every real error you'll encounter and exactly how to fix it.
Why Flask SaaS Setup Takes Longer Than Expected
The infrastructure trap nobody warns you about
Flask is minimal by design. That's its strength — and the trap.
With Django, you get auth, admin, and ORM out of the box. With Flask, you build everything from scratch. For a learning project, that's valuable. For a SaaS you're trying to ship, it means weeks of setup before you write a single line of your actual product.
The average Python developer building their first SaaS loses 4-8 weeks on infrastructure before touching the core product. JWT authentication alone — done correctly with refresh tokens, httpOnly cookies, and proper expiry — takes most developers 5-10 days.
What "setup hell" actually costs you
- Week 1-2: JWT authentication (with OAuth it's 3 weeks)
- Week 3: Stripe integration + webhook verification
- Week 4: CORS, deployment, environment variables
- Week 5+: The idea has lost momentum. Most projects stop here.
None of this is your fault. It's just the nature of building on a minimal framework without a production-ready foundation.
Flask JWT Authentication — Production-Ready Implementation
In this section you'll learn: How JWT authentication actually works in production, the difference between access and refresh tokens, a complete implementation you can copy, and the exact errors you'll hit before you get there.
Access tokens vs refresh tokens — what to use and why
Most tutorials give you a single JWT that expires in 7 days. That's a security hole in production.
The correct pattern:
- Access token: Short-lived (15 minutes). Stored in memory on the client. Used for every API request.
- Refresh token: Long-lived (7-30 days). Stored in an httpOnly cookie. Used only to get new access tokens.
This way, if an access token is stolen, it expires in 15 minutes. The refresh token is never accessible to JavaScript, so XSS attacks can't steal it.
Complete JWT implementation with Flask
# auth.py
import jwt
import datetime
from functools import wraps
from flask import request, jsonify, make_response
from models import User
SECRET_KEY = os.environ.get('JWT_SECRET_KEY')
ACCESS_TOKEN_EXPIRY = 15 # minutes
REFRESH_TOKEN_EXPIRY = 30 # days
def generate_tokens(user_id: int) -> dict:
access_payload = {
'user_id': user_id,
'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=ACCESS_TOKEN_EXPIRY),
'iat': datetime.datetime.utcnow(),
'type': 'access'
}
refresh_payload = {
'user_id': user_id,
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=REFRESH_TOKEN_EXPIRY),
'iat': datetime.datetime.utcnow(),
'type': 'refresh'
}
access_token = jwt.encode(access_payload, SECRET_KEY, algorithm='HS256')
refresh_token = jwt.encode(refresh_payload, SECRET_KEY, algorithm='HS256')
return {
'access_token': access_token,
'refresh_token': refresh_token
}
def require_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({'error': 'Missing or invalid authorization header'}), 401
token = auth_header.split(' ')[1]
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
if payload.get('type') != 'access':
return jsonify({'error': 'Invalid token type'}), 401
request.user_id = payload['user_id']
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Token expired', 'code': 'TOKEN_EXPIRED'}), 401
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token'}), 401
return f(*args, **kwargs)
return decorated
@app.route('/api/auth/login', methods=['POST'])
def login():
data = request.get_json()
user = User.query.filter_by(email=data['email']).first()
if not user or not user.check_password(data['password']):
return jsonify({'error': 'Invalid credentials'}), 401
tokens = generate_tokens(user.id)
response = make_response(jsonify({
'access_token': tokens['access_token'],
'user': user.to_dict()
}))
# Refresh token in httpOnly cookie — not accessible to JavaScript
response.set_cookie(
'refresh_token',
tokens['refresh_token'],
httponly=True,
secure=True, # HTTPS only in production
samesite='Lax',
max_age=60 * 60 * 24 * 30 # 30 days
)
return response
@app.route('/api/auth/refresh', methods=['POST'])
def refresh():
refresh_token = request.cookies.get('refresh_token')
if not refresh_token:
return jsonify({'error': 'No refresh token'}), 401
try:
payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=['HS256'])
if payload.get('type') != 'refresh':
return jsonify({'error': 'Invalid token type'}), 401
tokens = generate_tokens(payload['user_id'])
response = make_response(jsonify({
'access_token': tokens['access_token']
}))
response.set_cookie(
'refresh_token',
tokens['refresh_token'],
httponly=True,
secure=True,
samesite='Lax',
max_age=60 * 60 * 24 * 30
)
return response
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Refresh token expired'}), 401
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid refresh token'}), 401
Common JWT errors in production (and exact fixes)
Error: jwt.exceptions.DecodeError: Not enough segments
# ❌ Wrong — token has whitespace or is malformed
token = request.headers.get('Authorization') # Returns "Bearer eyJ..."
jwt.decode(token, SECRET_KEY, algorithms=['HS256']) # Fails — includes "Bearer "
# ✅ Correct
auth_header = request.headers.get('Authorization')
token = auth_header.split(' ')[1] # Split and take second part
jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
Error: jwt.exceptions.InvalidSignatureError
Your SECRET_KEY is different between token generation and verification. This happens when you have different environment variables in development vs production, or when you restart the server and the key changes.
# ❌ Wrong — key generated at runtime, changes on restart
SECRET_KEY = os.urandom(32)
# ✅ Correct — key from environment, consistent across restarts
SECRET_KEY = os.environ.get('JWT_SECRET_KEY')
if not SECRET_KEY:
raise ValueError("JWT_SECRET_KEY environment variable not set")
Error: Token valid locally, fails in production
Almost always a clock skew issue or timezone problem.
# ✅ Always use utcnow(), never datetime.now()
'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=15)
Stripe Webhooks in Flask — The Right Way
In this section you'll learn: Why webhook signature verification fails, a complete production webhook handler, how to test locally with Stripe CLI, and the exact errors that will hit you in production.
Why webhook signature verification fails
Stripe signs every webhook with your endpoint's signing secret. To verify it, you need the raw request body — not the parsed JSON.
This is where most developers break it:
# ❌ Wrong — Flask parses the body, signature verification fails
@app.route('/api/webhooks/stripe', methods=['POST'])
def stripe_webhook():
data = request.get_json() # Body already parsed — signature will never match
payload = json.dumps(data)
sig_header = request.headers.get('Stripe-Signature')
event = stripe.Webhook.construct_event(payload, sig_header, WEBHOOK_SECRET)
# ✅ Correct — use request.data for raw bytes
@app.route('/api/webhooks/stripe', methods=['POST'])
def stripe_webhook():
payload = request.data # Raw bytes — never parse this first
sig_header = request.headers.get('Stripe-Signature')
try:
event = stripe.Webhook.construct_event(
payload, sig_header, WEBHOOK_SECRET
)
except stripe.error.SignatureVerificationError:
return jsonify({'error': 'Invalid signature'}), 400
Production webhook handler — complete code
import stripe
import os
from flask import request, jsonify
stripe.api_key = os.environ.get('STRIPE_SECRET_KEY')
WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET')
@app.route('/api/webhooks/stripe', methods=['POST'])
def stripe_webhook():
payload = request.data
sig_header = request.headers.get('Stripe-Signature')
if not sig_header:
return jsonify({'error': 'Missing Stripe-Signature header'}), 400
try:
event = stripe.Webhook.construct_event(
payload, sig_header, WEBHOOK_SECRET
)
except ValueError:
return jsonify({'error': 'Invalid payload'}), 400
except stripe.error.SignatureVerificationError:
return jsonify({'error': 'Invalid signature'}), 400
# Handle events
event_type = event['type']
if event_type == 'checkout.session.completed':
handle_checkout_completed(event['data']['object'])
elif event_type == 'customer.subscription.deleted':
handle_subscription_cancelled(event['data']['object'])
elif event_type == 'invoice.payment_failed':
handle_payment_failed(event['data']['object'])
# Always return 200 — Stripe retries if you don't
return jsonify({'status': 'ok'}), 200
def handle_checkout_completed(session):
customer_email = session.get('customer_email')
customer_id = session.get('customer')
user = User.query.filter_by(email=customer_email).first()
if user:
user.stripe_customer_id = customer_id
user.is_paid = True
db.session.commit()
def handle_subscription_cancelled(subscription):
customer_id = subscription.get('customer')
user = User.query.filter_by(stripe_customer_id=customer_id).first()
if user:
user.is_paid = False
db.session.commit()
def handle_payment_failed(invoice):
customer_id = invoice.get('customer')
# Send email, notify user, etc.
pass
Testing webhooks locally with Stripe CLI
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to your local Flask server
stripe listen --forward-to localhost:5001/api/webhooks/stripe
# In another terminal, trigger a test event
stripe trigger checkout.session.completed
The CLI gives you a webhook signing secret (whsec_...) to use locally. Never use your production webhook secret for local testing.
Common webhook errors in production
Error: Events processing twice
Stripe retries webhooks if you don't respond with 200 within 30 seconds. Store processed event IDs to handle duplicates:
processed_events = set() # Use Redis in production
@app.route('/api/webhooks/stripe', methods=['POST'])
def stripe_webhook():
# ... verification code ...
event_id = event['id']
if event_id in processed_events:
return jsonify({'status': 'already processed'}), 200
processed_events.add(event_id)
# ... handle event ...
Error: 500 on webhook, Stripe keeps retrying
Your handler is throwing an unhandled exception. Stripe retries failed webhooks for 3 days. Always wrap handlers in try/except and log errors:
try:
handle_checkout_completed(event['data']['object'])
except Exception as e:
app.logger.error(f"Webhook handler error: {e}")
# Still return 200 to stop retries, or 500 to allow retry
return jsonify({'error': str(e)}), 500
CORS Configuration in Flask with Next.js
In this section you'll learn: Why CORS works locally but fails in production, exact CORS setup for Flask + Next.js, and how to fix the preflight request problem.
Why CORS works locally but breaks in production
Locally, your Next.js runs on localhost:3000 and Flask on localhost:5001. Some browsers are lenient with localhost. In production, you're hitting a real domain with HTTPS — and CORS becomes strict.
The second common cause: you're sending cookies or Authorization headers. Any request with credentials requires an explicit Access-Control-Allow-Credentials: true header AND a specific origin (wildcards won't work).
Production CORS setup for Flask + Next.js
# ❌ Wrong — wildcard origin won't work with credentials
from flask_cors import CORS
CORS(app, origins="*")
# ✅ Correct — explicit origins, with credentials support
from flask_cors import CORS
ALLOWED_ORIGINS = [
"http://localhost:3000",
"https://yourdomain.com",
"https://www.yourdomain.com",
]
CORS(app,
origins=ALLOWED_ORIGINS,
supports_credentials=True,
allow_headers=["Content-Type", "Authorization"],
methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"]
)
If you need more control, handle CORS manually:
@app.after_request
def add_cors_headers(response):
origin = request.headers.get('Origin')
if origin in ALLOWED_ORIGINS:
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
return response
@app.before_request
def handle_preflight():
if request.method == 'OPTIONS':
response = make_response()
origin = request.headers.get('Origin')
if origin in ALLOWED_ORIGINS:
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
response.headers['Access-Control-Max-Age'] = '3600'
return response, 200
The preflight request problem — and how to fix it
Browsers send an OPTIONS request before any cross-origin request with custom headers (like Authorization). If your Flask server doesn't handle OPTIONS, the actual request never goes through.
Error: CORS error on a POST request even with CORS configured
Check: Is OPTIONS handled? Is the origin in your allowlist? Is the response returning the right headers?
# Debug: print exactly what headers you're returning
@app.after_request
def debug_cors(response):
print(f"Origin: {request.headers.get('Origin')}")
print(f"CORS headers: {dict(response.headers)}")
return response
On the Next.js side:
// ✅ Correct fetch with credentials
const response = await fetch(`${API_URL}/api/protected-endpoint`, {
method: 'GET',
credentials: 'include', // Required for cookies
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
})
Setup Hell vs Production-Ready Foundation
| Without boilerplate | With production-ready base | |
|---|---|---|
| Time to first feature | 4-8 weeks | Day 1 |
| JWT auth | Build from scratch, debug for days | Already done |
| Stripe webhooks | Signature errors, retry loops | Production-ready handler included |
| CORS | Breaks in production, hours debugging | Configured for Next.js + Flask |
| OAuth (Google/GitHub) | 1-2 weeks per provider | Included |
| Email system | Another week | Integrated with templates |
| Mental overhead | Constant context switching | Focus on your product |
| Shipping speed | Month 2 before writing product code | Week 1 |
The cost isn't just time. It's momentum. Every week on infrastructure is a week you're not validating your idea.
FAQ — Quick Answers for Common Problems
How do I implement JWT authentication in Flask?
Use PyJWT. Generate an access token (15 min expiry) and a refresh token (30 days, stored in httpOnly cookie). Verify the access token on every protected route with a decorator. See the complete implementation above.
Why is my Stripe webhook signature verification failing?
You're parsing the request body before verification. Use request.data (raw bytes) instead of request.get_json(). The raw payload must match exactly what Stripe sent — any parsing modifies it and breaks the signature.
How do I fix CORS errors in Flask with React/Next.js?
Don't use origins="*" if you're sending credentials. Set explicit allowed origins and supports_credentials=True. Handle OPTIONS preflight requests. On the frontend, set credentials: 'include' on fetch calls.
What's the best Python SaaS boilerplate in 2026?
Most boilerplates are JavaScript-only (ShipFast, SaaSBold). If you're building with Flask + Next.js, you need something that covers both sides — JWT auth, Stripe webhooks, CORS, OAuth, and email, all pre-configured and production-ready.
Why does my JWT work locally but fail in production?
Three most common causes: different SECRET_KEY between environments, using datetime.now() instead of datetime.utcnow(), or the token type check failing (verify you're sending an access token, not a refresh token, in the Authorization header).
You Can Build All This Yourself. Or You Can Ship Faster.
Everything in this guide is buildable. The code is here. The patterns are solid. If you follow it carefully, you'll have a working auth system, Stripe integration, and CORS configuration in a few days.
But you'll also spend those days debugging instead of building your product.
That's why I built LaunchStack — a Next.js + Flask boilerplate that ships with all of this pre-configured and production-ready. JWT authentication with refresh tokens. Stripe webhooks with proper signature verification. CORS set up for Next.js. Google and GitHub OAuth. Email system with templates. Admin dashboard.
Everything in this article, already working on day one.
If you're building a Python SaaS and want to skip straight to your actual product — LaunchStack launches February 24. Early bird pricing at $99 before it goes to $169.
The infrastructure trap is real. It's also completely avoidable.
Skip the setup.
Ship your product.
Everything in this guide — JWT auth, Stripe webhooks, CORS — pre-built and production-ready. Start with a working foundation on day one.