I'm a Python dev building a SaaS.
Here's what my first month actually looked like:
- Week 1: JWT auth. Errors I didn't understand.
- Week 2: OAuth loops at 2AM.
- Week 3: Stripe webhooks failing in production.
- Month 1: Still no real feature shipped.
The idea wasn't bad. I was stuck in setup hell.
Most tutorials show "how to make it work" — not how to make it production-safe. So I documented everything that broke, every edge case, every mistake.
Here's the exact production-ready code I ended up with.
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.
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.
Flask JWT Authentication — Production-Ready
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.
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
# 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'
}
return {
'access_token': jwt.encode(access_payload, SECRET_KEY, algorithm='HS256'),
'refresh_token': jwt.encode(refresh_payload, SECRET_KEY, algorithm='HS256')
}
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 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
Login endpoint with refresh token in httpOnly cookie
@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()
}))
response.set_cookie(
'refresh_token',
tokens['refresh_token'],
httponly=True,
secure=True,
samesite='Lax',
max_age=60 * 60 * 24 * 30
)
return response
Common JWT errors and exact fixes
DecodeError: Not enough segments
# ❌ Wrong — includes "Bearer " prefix
token = request.headers.get('Authorization')
jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
# ✅ Correct — split and take the token part
auth_header = request.headers.get('Authorization')
token = auth_header.split(' ')[1]
InvalidSignatureError
Your SECRET_KEY is different between environments:
# ❌ Wrong — changes on every restart
SECRET_KEY = os.urandom(32)
# ✅ Correct — consistent across restarts
SECRET_KEY = os.environ.get('JWT_SECRET_KEY')
Token works locally, fails in production
Always use utcnow():
# ✅ Always use utcnow(), never datetime.now()
'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=15)
Stripe Webhooks — Why Yours Are Failing
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 — body already parsed, signature will never match
@app.route('/api/webhooks/stripe', methods=['POST'])
def stripe_webhook():
data = request.get_json()
payload = json.dumps(data)
sig_header = request.headers.get('Stripe-Signature')
event = stripe.Webhook.construct_event(payload, sig_header, WEBHOOK_SECRET)
# ✅ Correct — raw bytes, never parse first
@app.route('/api/webhooks/stripe', methods=['POST'])
def stripe_webhook():
payload = request.data # Raw bytes
sig_header = request.headers.get('Stripe-Signature')
event = stripe.Webhook.construct_event(payload, sig_header, WEBHOOK_SECRET)
Production webhook handler
@app.route('/api/webhooks/stripe', methods=['POST'])
def stripe_webhook():
payload = request.data
sig_header = request.headers.get('Stripe-Signature')
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
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'])
return jsonify({'status': 'ok'}), 200
Testing locally with Stripe CLI
# Forward webhooks to your local Flask server
stripe listen --forward-to localhost:5001/api/webhooks/stripe
# Trigger a test event
stripe trigger checkout.session.completed
CORS — Why It Works Locally But Breaks in Production
Locally, browsers are lenient with localhost. In production with HTTPS, CORS becomes strict. Any request with credentials requires explicit Access-Control-Allow-Credentials: true AND a specific origin (wildcards won't work).
# ❌ Wrong — wildcard won't work with credentials
CORS(app, origins="*")
# ✅ Correct — explicit origins with credentials
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"]
)
On the Next.js side:
const response = await fetch(`${API_URL}/api/endpoint`, {
method: 'GET',
credentials: 'include',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
})
The Real Cost
| Without boilerplate | With production-ready base | |
|---|---|---|
| Time to first feature | 4-8 weeks | Day 1 |
| JWT auth | Build from scratch | Already done |
| Stripe webhooks | Signature errors, retry loops | Production-ready |
| CORS | Breaks in production | Configured |
| OAuth | 1-2 weeks per provider | Included |
The cost isn't just time. It's momentum. Every week on infrastructure is a week you're not validating your idea.
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.
But you'll 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. JWT auth with refresh tokens, Stripe webhooks, CORS, Google & GitHub OAuth, email system, admin dashboard.
Everything in this article, already working on day one.
Launching February 24. Early bird at $99.
Have questions about any of the code above? Drop a comment — I'll answer everything.
Top comments (0)