DEV Community

Launchstack310
Launchstack310

Posted on • Originally published at launchstack.space

I wasted 3 weeks on Flask auth before writing a single feature. Here's the production-ready setup.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

Token works locally, fails in production

Always use utcnow():

# ✅ Always use utcnow(), never datetime.now()
'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=15)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
)
Enter fullscreen mode Exit fullscreen mode

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',
  },
})
Enter fullscreen mode Exit fullscreen mode

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.

👉 launchstack.space


Have questions about any of the code above? Drop a comment — I'll answer everything.

Top comments (0)