Skip to content

CWE-614: Sensitive Cookie in HTTPS Session Without 'Secure' Attribute - Python

Overview

This finding fires when a Python web application sets a cookie carrying sensitive data (session IDs, authentication tokens, user identifiers) without the Secure flag. The browser then attaches that cookie to plain HTTP requests as well as HTTPS ones, so a man-in-the-middle on the network path can read the value from a single plaintext request and replay it to take over the session.

Common Python Vulnerability Scenarios:

  • Setting session cookies without secure=True in Flask
  • Django session cookies without SESSION_COOKIE_SECURE = True
  • FastAPI cookies missing secure parameter
  • Custom authentication cookies without proper flags
  • Remember-me cookies transmitted over HTTP
  • OAuth state cookies without secure flag

Python Framework Cookie Security:

  • Flask: response.set_cookie(secure=True, httponly=True, samesite='Strict')
  • Django: SESSION_COOKIE_SECURE = True in settings
  • FastAPI: response.set_cookie(secure=True, httponly=True, samesite='strict')
  • Bottle: response.set_cookie(secure=True, httponly=True)

Primary Defence: Set secure=True on every cookie containing sensitive data, and enable SESSION_COOKIE_SECURE in production. secure is the fix for this finding and has no legitimate exception on an HTTPS site. httponly=True belongs on any cookie no page script needs to read, which is nearly all of them. SameSite is chosen per flow, not set to Strict by default: use Strict only where nothing legitimate navigates in from another site, and Lax for OAuth/SSO callbacks and ordinary inbound links.

Common Vulnerable Patterns

# VULNERABLE - Session cookie without Secure flag
from flask import Flask, request, make_response
import secrets

app = Flask(__name__)

@app.route('/login', methods=['POST'])
def login():
    username = request.form.get('username')
    password = request.form.get('password')

    if authenticate_user(username, password):
        session_token = secrets.token_hex(32)

        response = make_response({'status': 'logged_in'})

        # VULNERABLE - Missing secure=True
        response.set_cookie(
            'session_id',
            session_token,
            httponly=True,
            max_age=3600
        )

        return response

    return {'error': 'Invalid credentials'}, 401

def authenticate_user(username, password):
    # Authentication logic
    return True

Why this is vulnerable:

  • secure defaults to False, so the browser sends session_id on any plain HTTP request to the host
  • Anyone on the network path reads the session token out of that request and replays it
  • httponly=True keeps the cookie away from page scripts; it says nothing about the transport
settings.py
# VULNERABLE - SESSION_COOKIE_SECURE not set to True
DEBUG = False
ALLOWED_HOSTS = ['example.com']

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
]

# VULNERABLE - These should be True in production
SESSION_COOKIE_SECURE = False  # BAD!
CSRF_COOKIE_SECURE = False  # BAD!
SESSION_COOKIE_HTTPONLY = True  # Good, but not enough
views.py
from django.contrib.auth import login
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods

@require_http_methods(["POST"])
def user_login(request):
    username = request.POST.get('username')
    password = request.POST.get('password')

    user = authenticate(request, username=username, password=password)

    if user is not None:
        # VULNERABLE - Session cookie sent without Secure flag
        login(request, user)
        return JsonResponse({'status': 'logged_in'})

    return JsonResponse({'error': 'Invalid credentials'}, status=401)

Why this is vulnerable:

  • SESSION_COOKIE_SECURE = False lets Django send the session cookie over plain HTTP, where it can be intercepted
  • CSRF_COOKIE_SECURE = False exposes the CSRF token the same way
  • DEBUG = False and a real ALLOWED_HOSTS mark this as a production settings file, so these are the deployed values
# VULNERABLE - FastAPI custom authentication without secure cookies
from fastapi import FastAPI, Response, Request, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
import secrets
from datetime import datetime, timedelta

app = FastAPI()

# In-memory session store (demo purposes)
sessions = {}

class LoginRequest(BaseModel):
    username: str
    password: str

@app.post("/login")
async def login(request: LoginRequest, response: Response):
    if authenticate_user(request.username, request.password):
        session_token = secrets.token_urlsafe(32)

        # Store session
        sessions[session_token] = {
            'username': request.username,
            'created_at': datetime.now()
        }

        # VULNERABLE - Missing secure=True
        response.set_cookie(
            key="session_token",
            value=session_token,
            httponly=True,
            max_age=1800,
            samesite="lax"
        )

        return {"status": "logged_in"}

    raise HTTPException(status_code=401, detail="Invalid credentials")

def authenticate_user(username: str, password: str) -> bool:
    # Authentication logic
    return True

Why this is vulnerable:

  • The secure parameter is left at its False default, so the browser sends session_token over plain HTTP
  • An observer on the connection gets the session token itself, so replaying it in a request is enough to be that user
  • samesite="lax" and httponly=True limit who can send or read the cookie, not what it travels over
# VULNERABLE - Remember-me functionality with insecure cookies
from flask import Flask, request, make_response
import secrets
import hashlib
from datetime import datetime, timedelta

app = Flask(__name__)

@app.route('/login', methods=['POST'])
def login_with_remember():
    username = request.form.get('username')
    password = request.form.get('password')
    remember_me = request.form.get('remember_me') == 'true'

    if authenticate_user(username, password):
        session_token = secrets.token_hex(32)

        response = make_response({'status': 'logged_in'})

        # Session cookie (also vulnerable, but short-lived)
        response.set_cookie(
            'session_id',
            session_token,
            httponly=True,
            max_age=3600
        )

        if remember_me:
            # VULNERABLE - Long-lived remember-me cookie without Secure flag
            remember_token = secrets.token_hex(64)

            # Store token in database
            store_remember_token(username, remember_token)

            response.set_cookie(
                'remember_me',
                remember_token,
                httponly=True,
                max_age=30*24*3600  # 30 days - VERY vulnerable!
            )

        return response

    return {'error': 'Invalid credentials'}, 401

def store_remember_token(username, token):
    # Database storage
    pass

def authenticate_user(username, password):
    return True

Why this is vulnerable:

  • Neither cookie carries secure, so both are sent on plain HTTP requests to the host
  • The remember-me cookie lives for 30 days, so one plaintext request at any point in that window hands over a credential that is still valid afterwards
  • Unlike the hour-long session cookie, capturing it gets the attacker back in long after the victim has closed the browser
# VULNERABLE - Custom JWT cookie without proper security
from flask import Flask, request, make_response
import jwt
from datetime import datetime, timedelta, timezone

app = Flask(__name__)
SECRET_KEY = 'your-secret-key'

@app.route('/api/login', methods=['POST'])
def api_login():
    username = request.json.get('username')
    password = request.json.get('password')

    if authenticate_user(username, password):
        # Create JWT token
        payload = {
            'username': username,
            'exp': datetime.now(timezone.utc) + timedelta(hours=1)
        }

        token = jwt.encode(payload, SECRET_KEY, algorithm='HS256')

        response = make_response({'status': 'success'})

        # VULNERABLE - Multiple issues
        response.set_cookie(
            'auth_token',
            token,
            max_age=3600
            # Missing: secure=True
            # Missing: httponly=True
            # Missing: samesite attribute
        )

        return response

    return {'error': 'Authentication failed'}, 401

def authenticate_user(username, password):
    return True

Why this is vulnerable:

  • No secure=True, so the browser sends the JWT over plain HTTP
  • No httponly=True, so any script running on the origin can read the JWT out of document.cookie
  • No samesite, so nothing in the code states the cookie's cross-site behaviour and it falls to whatever the browser defaults to
# VULNERABLE - OAuth state cookie without security flags
from flask import Flask, request, redirect, make_response
import secrets

app = Flask(__name__)

@app.route('/oauth/authorize')
def oauth_authorize():
    # Generate CSRF protection state
    state = secrets.token_urlsafe(32)

    # VULNERABLE - OAuth state cookie without Secure flag
    response = make_response(redirect(
        f'https://oauth.provider.com/authorize?'
        f'client_id=YOUR_CLIENT_ID&'
        f'redirect_uri=https://example.com/oauth/callback&'
        f'state={state}'
    ))

    response.set_cookie(
        'oauth_state',
        state,
        max_age=600  # 10 minutes
        # Missing: secure=True, httponly=True, samesite
    )

    return response

@app.route('/oauth/callback')
def oauth_callback():
    state_param = request.args.get('state')
    state_cookie = request.cookies.get('oauth_state')

    if state_param != state_cookie:
        return {'error': 'Invalid state'}, 400

    # Continue OAuth flow
    return {'status': 'success'}

Why this is vulnerable:

  • The state value travels in the clear, so a network observer learns it. It is correlation and CSRF data, not an access token - reading it does not by itself steal an OAuth token.
  • Knowing state lets an attacker forge a callback the application accepts, which is login CSRF: the victim's browser is silently signed in to the attacker's account, and anything the victim then does lands there.
  • It also enables authorization-code injection, where an attacker replays their own code against the victim's session with a state the check now approves.
  • Without httpOnly any XSS can read it too, and without secure the value is exposed on every plaintext request the browser makes to the host.

Bottle Framework Without Secure Cookies

# VULNERABLE - Bottle application with insecure cookies
from bottle import Bottle, request, response
import secrets

app = Bottle()

@app.post('/login')
def login():
    username = request.forms.get('username')
    password = request.forms.get('password')

    if authenticate_user(username, password):
        session_id = secrets.token_hex(32)

        # VULNERABLE - Bottle cookie without secure flag
        response.set_cookie(
            'session_id',
            session_id,
            max_age=3600,
            httponly=True
            # Missing: secure=True
        )

        return {'status': 'logged_in'}

    return {'error': 'Invalid credentials'}

def authenticate_user(username, password):
    return True

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8080)

Why this is vulnerable:

  • Bottle's response.set_cookie() omits Secure unless you pass secure=True, so the default is the insecure one
  • The session ID is sent on plain HTTP requests, where it can be captured and replayed against the application

Pyramid Framework Without Secure Cookies

# VULNERABLE - Pyramid session without secure cookies
from pyramid.config import Configurator
from pyramid.view import view_config
from pyramid.response import Response
import secrets

@view_config(route_name='login', request_method='POST', renderer='json')
def login_view(request):
    username = request.POST.get('username')
    password = request.POST.get('password')

    if authenticate_user(username, password):
        session_token = secrets.token_hex(32)

        # VULNERABLE - Pyramid cookie without secure flag
        response = Response(json_body={'status': 'logged_in'})
        response.set_cookie(
            'session_id',
            session_token,
            max_age=3600,
            httponly=True
            # Missing: secure=True
        )

        return response

    return Response(json_body={'error': 'Invalid credentials'}, status=401)

def authenticate_user(username, password):
    return True

def main(global_config, **settings):
    config = Configurator(settings=settings)
    config.add_route('login', '/login')
    config.scan()
    return config.make_wsgi_app()

Why this is vulnerable:

  • The set_cookie() call omits secure, so session_id goes out on plain HTTP as well as HTTPS
  • Whoever reads it from a plaintext request holds the session ID itself, which the application accepts from anyone who presents it

Secure Patterns

# SECURE - Flask cookie with proper security flags
from flask import Flask, request, make_response
import secrets
from datetime import timedelta

app = Flask(__name__)

# SECURE - Configure session cookie security
app.config.update(
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE='Strict',
    PERMANENT_SESSION_LIFETIME=timedelta(hours=1)
)

@app.route('/login', methods=['POST'])
def secure_login():
    username = request.form.get('username')
    password = request.form.get('password')

    if authenticate_user(username, password):
        session_token = secrets.token_hex(32)

        response = make_response({'status': 'logged_in'})

        # SECURE - All critical security flags set
        response.set_cookie(
            'session_id',
            session_token,
            secure=True,        # Only sent over HTTPS
            httponly=True,      # Not accessible via JavaScript
            samesite='Strict',  # CSRF protection
            max_age=3600        # 1 hour expiration
        )

        return response

    return {'error': 'Invalid credentials'}, 401

def authenticate_user(username, password):
    # Secure authentication logic
    return True

if __name__ == '__main__':
    # SECURE - Only run on HTTPS in production
    # Use a proper WSGI server (gunicorn, uWSGI) with TLS
    app.run(ssl_context='adhoc')  # For development only

Why this works:

  • secure=True keeps the cookie off plaintext requests, httponly=True keeps it out of document.cookie, and samesite='Strict' adds CSRF defense-in-depth.
  • The SESSION_COOKIE_* config covers Flask's own session cookie; the set_cookie() call sets the same three flags on the cookie this view issues.
  • The cookie and the session both expire after an hour, and secrets.token_hex(32) makes the token unguessable.
# settings.py - SECURE configuration

import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False

ALLOWED_HOSTS = ['example.com', 'www.example.com']

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
]

# SECURE - Session cookie security settings
SESSION_COOKIE_SECURE = True        # Only send over HTTPS
SESSION_COOKIE_HTTPONLY = True      # Not accessible via JavaScript
SESSION_COOKIE_SAMESITE = 'Strict'  # CSRF protection
SESSION_COOKIE_AGE = 3600           # 1 hour

# SECURE - CSRF cookie security
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = 'Strict'
# CSRF_COOKIE_HTTPONLY is deliberately left at its False default - see below

# SECURE - Additional security settings
SECURE_SSL_REDIRECT = True          # Redirect HTTP to HTTPS
SECURE_HSTS_SECONDS = 31536000      # HTTP Strict Transport Security
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_CONTENT_TYPE_NOSNIFF = True
# Note: SECURE_BROWSER_XSS_FILTER was removed in Django 4.0. It set
# X-XSS-Protection, which no current browser honours. Use Content-Security-Policy.

# views.py
from django.contrib.auth import authenticate, login
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.views.decorators.csrf import ensure_csrf_cookie
from django.middleware.csrf import get_token

@require_http_methods(["POST"])
def secure_login(request):
    username = request.POST.get('username')
    password = request.POST.get('password')

    user = authenticate(request, username=username, password=password)

    if user is not None:
        # SECURE - Django automatically applies SESSION_COOKIE_SECURE
        login(request, user)
        return JsonResponse({'status': 'logged_in'})

    return JsonResponse({'error': 'Invalid credentials'}, status=401)

@ensure_csrf_cookie
def get_csrf_token(request):
    # SECURE - the cookie carries Secure; the body carries the value for AJAX
    return JsonResponse({'csrfToken': get_token(request)})

Why this works:

  • App-wide settings enforce Secure/HttpOnly/SameSite on session and CSRF cookies.
  • HTTPS redirect + HSTS prevent downgrade and plaintext transport.
  • Short session age limits exposure.

SESSION_COOKIE_HTTPONLY and CSRF_COOKIE_HTTPONLY are not the same decision, and setting both by reflex breaks the application. Django's own documentation says HttpOnly on the CSRF cookie "doesn't offer any practical protection" - an attacker who can read it via JavaScript is already executing on the origin and can do anything anyway. What it does do is stop your own front end reading document.cookie to populate the X-CSRFToken header, which is how every JavaScript client Django documents obtains the token. If an auditor requires the flag, enable it and change the client to read the value from a hidden form input or from a view like the one above; do not enable it and leave the client reading the cookie, because the resulting 403s look like a CSRF bug rather than a configuration choice. CSRF_COOKIE_SECURE is the setting this finding is about, and it is unaffected either way.

FastAPI With Secure Cookies

# SECURE - FastAPI with proper cookie security
from fastapi import FastAPI, Response, Request, HTTPException, Depends
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from pydantic import BaseModel
import secrets
from datetime import datetime, timedelta
from typing import Optional

app = FastAPI()

# In-memory session store (use Redis in production)
sessions = {}

class LoginRequest(BaseModel):
    username: str
    password: str

security = HTTPBearer()

def get_current_user(request: Request):
    """Dependency to validate session"""
    session_token = request.cookies.get("session_token")

    if not session_token or session_token not in sessions:
        raise HTTPException(status_code=401, detail="Not authenticated")

    session = sessions[session_token]

    # Check expiration
    if datetime.now() > session['expires_at']:
        del sessions[session_token]
        raise HTTPException(status_code=401, detail="Session expired")

    return session['username']

@app.post("/login")
async def secure_login(request: LoginRequest, response: Response):
    if authenticate_user(request.username, request.password):
        session_token = secrets.token_urlsafe(32)

        # Store session with expiration
        sessions[session_token] = {
            'username': request.username,
            'created_at': datetime.now(),
            'expires_at': datetime.now() + timedelta(hours=1)
        }

        # SECURE - All security flags set
        response.set_cookie(
            key="session_token",
            value=session_token,
            secure=True,        # HTTPS only
            httponly=True,      # Not accessible via JavaScript
            samesite="strict",  # CSRF protection
            max_age=3600        # 1 hour
        )

        return {"status": "logged_in"}

    raise HTTPException(status_code=401, detail="Invalid credentials")

@app.post("/logout")
async def logout(request: Request, response: Response):
    session_token = request.cookies.get("session_token")

    if session_token and session_token in sessions:
        del sessions[session_token]

    # Delete cookie
    response.delete_cookie(
        key="session_token",
        secure=True,
        httponly=True,
        samesite="strict"
    )

    return {"status": "logged_out"}

@app.get("/protected")
async def protected_route(username: str = Depends(get_current_user)):
    return {"message": f"Hello {username}", "protected": True}

def authenticate_user(username: str, password: str) -> bool:
    # Secure authentication logic
    return True

if __name__ == "__main__":
    import uvicorn
    # SECURE - Run with TLS in production
    uvicorn.run(
        app,
        host="0.0.0.0",
        port=8443,
        ssl_keyfile="path/to/key.pem",
        ssl_certfile="path/to/cert.pem"
    )

Why this works:

  • Secure/HttpOnly/SameSite enforce HTTPS-only cookies, reduce JavaScript cookie theft, and add CSRF defense-in-depth.
  • Expiration checks and logout cleanup invalidate stolen tokens quickly.
  • TLS config + strong tokens reduce interception and guessing risk.

Secure Remember-Me Implementation

# SECURE - Remember-me with proper security
from flask import Flask, request, make_response
import secrets
import hashlib
from datetime import datetime, timedelta
import os

app = Flask(__name__)

# SECURE - App configuration. These govern Flask's own `session` cookie only.
# This example issues its own cookies with response.set_cookie() below, so the
# flags that close the finding are the ones passed there, not these.
app.config.update(
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE='Strict',
)

class RememberMeToken:
    """Secure remember-me token management"""

    @staticmethod
    def generate_token():
        """Generate cryptographically secure token"""
        return secrets.token_urlsafe(64)

    @staticmethod
    def hash_token(token):
        """Hash token for storage"""
        return hashlib.sha256(token.encode()).hexdigest()

    @staticmethod
    def store_token(username, token):
        """Store hashed token in database"""
        token_hash = RememberMeToken.hash_token(token)

        # Store in database with expiration
        db.remember_tokens.insert({
            'username': username,
            'token_hash': token_hash,
            'created_at': datetime.now(),
            'expires_at': datetime.now() + timedelta(days=30)
        })

    @staticmethod
    def verify_token(token):
        """Verify remember-me token"""
        token_hash = RememberMeToken.hash_token(token)

        token_record = db.remember_tokens.find_one({
            'token_hash': token_hash,
            'expires_at': {'$gt': datetime.now()}
        })

        if token_record:
            return token_record['username']

        return None

@app.route('/login', methods=['POST'])
def login_with_remember():
    username = request.form.get('username')
    password = request.form.get('password')
    remember_me = request.form.get('remember_me') == 'true'

    if authenticate_user(username, password):
        session_token = secrets.token_hex(32)

        response = make_response({'status': 'logged_in'})

        # SECURE - Session cookie with all flags
        response.set_cookie(
            'session_id',
            session_token,
            secure=True,
            httponly=True,
            samesite='Strict',
            max_age=3600
        )

        if remember_me:
            remember_token = RememberMeToken.generate_token()
            RememberMeToken.store_token(username, remember_token)

            # SECURE - Remember-me cookie with all flags
            response.set_cookie(
                'remember_me',
                remember_token,
                secure=True,      # HTTPS only
                httponly=True,    # Not accessible via JavaScript
                samesite='Strict', # CSRF protection
                max_age=30*24*3600 # 30 days
            )

        return response

    return {'error': 'Invalid credentials'}, 401

@app.route('/auto-login', methods=['POST'])
def auto_login():
    """Auto-login using remember-me token"""
    remember_token = request.cookies.get('remember_me')

    if not remember_token:
        return {'error': 'No remember-me token'}, 401

    username = RememberMeToken.verify_token(remember_token)

    if username:
        session_token = secrets.token_hex(32)

        response = make_response({'status': 'auto_logged_in', 'username': username})

        # SECURE - Create new session
        response.set_cookie(
            'session_id',
            session_token,
            secure=True,
            httponly=True,
            samesite='Strict',
            max_age=3600
        )

        return response

    return {'error': 'Invalid token'}, 401

def authenticate_user(username, password):
    return True

# Mock database
class MockDB:
    def __init__(self):
        self.remember_tokens = MockCollection()

class MockCollection:
    def __init__(self):
        self.data = []

    def insert(self, doc):
        self.data.append(doc)

    def find_one(self, query):
        # Simple mock implementation
        return None

db = MockDB()

Why this works:

  • Secure/HttpOnly/SameSite reduce transport, JavaScript access, and cross-site request risk for both session and remember-me cookies.
  • Tokens are hashed at rest and expire, limiting replay and DB compromise impact.
  • Auto-login issues a fresh short-lived session cookie.

Note which line actually does the work. SESSION_COOKIE_* configures the signed cookie behind flask.session, and the REMEMBER_COOKIE_* keys that often appear beside it belong to Flask-Login, not to Flask. Neither applies to a cookie your own code writes with response.set_cookie(). A page that sets the config block and omits secure=True from the set_cookie calls looks configured and is not - which is the most common way this finding survives a fix. If the application does use Flask-Login, set REMEMBER_COOKIE_SECURE, REMEMBER_COOKIE_HTTPONLY and REMEMBER_COOKIE_SAMESITE as well, because its remember cookie is a separate cookie again.

# SECURE - JWT in cookie with all security flags
from flask import Flask, request, make_response
import jwt
from datetime import datetime, timedelta, timezone
import os

app = Flask(__name__)

# SECURE - Secret injected at start-up from a secret store, not kept in the environment (CWE-526)
JWT_SECRET = os.environ.get('JWT_SECRET_KEY')
if not JWT_SECRET:
    raise ValueError("JWT_SECRET_KEY environment variable not set")

@app.route('/api/login', methods=['POST'])
def secure_api_login():
    username = request.json.get('username')
    password = request.json.get('password')

    if authenticate_user(username, password):
        # Create JWT with expiration
        payload = {
            'username': username,
            'iat': datetime.now(timezone.utc),
            'exp': datetime.now(timezone.utc) + timedelta(hours=1)
        }

        token = jwt.encode(payload, JWT_SECRET, algorithm='HS256')

        response = make_response({'status': 'success'})

        # SECURE - All security flags set
        response.set_cookie(
            'auth_token',
            token,
            secure=True,        # HTTPS only
            httponly=True,      # Not accessible via JavaScript
            samesite='Strict',  # CSRF protection
            max_age=3600        # 1 hour
        )

        return response

    return {'error': 'Authentication failed'}, 401

@app.route('/api/protected', methods=['GET'])
def protected_api():
    auth_token = request.cookies.get('auth_token')

    if not auth_token:
        return {'error': 'Not authenticated'}, 401

    try:
        # Verify JWT
        payload = jwt.decode(
            auth_token,
            JWT_SECRET,
            algorithms=['HS256']
        )

        return {
            'message': f'Hello {payload["username"]}',
            'protected': True
        }
    except jwt.ExpiredSignatureError:
        return {'error': 'Token expired'}, 401
    except jwt.InvalidTokenError:
        return {'error': 'Invalid token'}, 401

def authenticate_user(username, password):
    return True

Why this works:

  • Secure/HttpOnly/SameSite cookies reduce JWT exposure through HTTP leakage, JavaScript cookie theft, and cross-site requests.
  • Expiration is enforced by both cookie max-age and JWT exp.
  • The signing key is read at start-up and the application refuses to start without it, so there is no hardcoded fallback.
# SECURE - OAuth state cookie with proper security
from flask import Flask, request, redirect, make_response, session
from datetime import datetime, timedelta
import secrets
import os

app = Flask(__name__)
app.secret_key = os.environ.get('FLASK_SECRET_KEY')
if not app.secret_key:
    raise RuntimeError("FLASK_SECRET_KEY environment variable not set")

# SECURE - Configure session security
app.config.update(
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE='Lax'  # Lax for OAuth redirects
)

OAUTH_CLIENT_ID = os.environ.get('OAUTH_CLIENT_ID')
OAUTH_CLIENT_SECRET = os.environ.get('OAUTH_CLIENT_SECRET')

@app.route('/oauth/authorize')
def oauth_authorize():
    # Generate CSRF protection state
    state = secrets.token_urlsafe(32)

    # Store state in session (automatically uses secure cookies)
    session['oauth_state'] = state
    session['oauth_initiated_at'] = datetime.now().isoformat()

    # Build OAuth URL
    oauth_url = (
        f'https://oauth.provider.com/authorize?'
        f'client_id={OAUTH_CLIENT_ID}&'
        f'redirect_uri=https://example.com/oauth/callback&'
        f'response_type=code&'
        f'state={state}&'
        f'scope=read:user'
    )

    return redirect(oauth_url)

@app.route('/oauth/callback')
def oauth_callback():
    state_param = request.args.get('state')
    state_session = session.get('oauth_state')
    initiated_at = session.get('oauth_initiated_at')

    # Verify state parameter
    if not state_param or state_param != state_session:
        return {'error': 'Invalid state parameter'}, 400

    # Check state age (prevent replay)
    if initiated_at:
        initiated_time = datetime.fromisoformat(initiated_at)
        if datetime.now() - initiated_time > timedelta(minutes=10):
            return {'error': 'State expired'}, 400

    # Clear state from session
    session.pop('oauth_state', None)
    session.pop('oauth_initiated_at', None)

    # Exchange code for token
    code = request.args.get('code')

    # Continue OAuth flow...
    return {'status': 'success'}

Why this works:

  • OAuth state is stored in a secure session cookie (Lax for redirects).
  • State validation + short expiration block CSRF and replay.
  • Single-use cleanup and strong state tokens reduce abuse.

Considerations

Which cookies this finding is about. Session identifiers, authentication and remember-me tokens, CSRF tokens, and anything carrying user identity need the full attribute set. A cookie holding a theme choice or a collapsed sidebar does not, and recording it as a false positive with the reason is a legitimate outcome. The question is whether reading the cookie gets an attacker something, not whether the framework happened to set it.

Strict is not a free upgrade over Lax. Every example above uses samesite='Strict' because they issue cookies for flows that begin on the site's own pages. Set SESSION_COOKIE_SAMESITE = 'Strict' on a Django site whose users arrive from email links, search results, or an SSO provider, and the browser withholds the session cookie on that first navigation: the user lands on the page logged out, then appears logged in after any same-site click. It reads as a session bug and it is a configuration choice. Lax withholds the cookie from cross-site POSTs and subresource loads - which is the CSRF-relevant part - while sending it on top-level navigations. Choose Strict when nothing legitimate navigates in from elsewhere, and treat OAuth and SSO callbacks as requiring Lax outright, because the provider's redirect is a cross-site navigation.

Proxy configuration does not emit the attribute, and does not need to. Django's SESSION_COOKIE_SECURE and Flask's SESSION_COOKIE_SECURE set Secure unconditionally - the cookie is marked regardless of what scheme the application thinks the request arrived on, so an unconfigured proxy cannot silently strip the flag the way express-session can in Node. Setting the flag is sufficient to close this finding.

What SECURE_PROXY_SSL_HEADER (and ProxyFix or uvicorn's --proxy-headers) governs is everything else that depends on the original scheme: request.is_secure(), SECURE_SSL_REDIRECT - which without it sees plain HTTP forever and redirects in a loop - absolute URL generation, and any branch of your own code that tests the scheme. Configure it because those need to be right, not because the cookie attribute depends on it. Set it only where the proxy is trusted to overwrite the header: trusting a forwarded-proto header from arbitrary clients lets a caller assert HTTPS for a plaintext request.

Testing

  • Sign in over HTTPS and assert every sensitive Set-Cookie carries Secure, HttpOnly and a SameSite value, then assert the next request is authenticated. Both halves matter: the flags being present does not prove the cookie is usable.
  • Follow a link into the application from a different origin and assert the user is still signed in. This is the assertion SESSION_COOKIE_SAMESITE = 'Strict' fails, and it fails silently - no error, no scanner finding.
  • Complete an OAuth round trip and assert the callback succeeds rather than returning Invalid state.
  • After signing in, request the same host over plain HTTP and assert the browser sends no session or authentication cookie in the request. Secure governs what the browser transmits, not what the server writes.
  • Exercise the deployed ingress, not a local HTTPS run: browsers special-case localhost and accept Secure cookies there over plain HTTP, so a passing local test says nothing about production.

Common Pitfalls

  • Setting SESSION_COOKIE_SECURE = True in Django and treating the finding as resolved - it only covers the framework's session cookie (and CSRF_COOKIE_SECURE separately covers the CSRF cookie); any cookie set manually with HttpResponse.set_cookie() in a view still needs secure=True passed explicitly.
  • Testing a secure=True cookie only against localhost over plain HTTP during development - most browsers special-case localhost and still accept Secure cookies there, so a passing local test doesn't verify behavior on a real HTTPS deployment behind a reverse proxy.
  • Enabling SECURE_SSL_REDIRECT without configuring SECURE_PROXY_SSL_HEADER behind a reverse proxy - Django can't tell the request arrived over HTTPS at the proxy, leading teams to disable the redirect to stop a loop, which quietly undermines the HTTPS-only guarantee that SESSION_COOKIE_SECURE is meant to rely on.

Additional Resources