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=Truein Flask - Django session cookies without
SESSION_COOKIE_SECURE = True - FastAPI cookies missing
secureparameter - 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 = Truein 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
Flask Cookie Without Secure Flag
# 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:
securedefaults toFalse, so the browser sendssession_idon any plain HTTP request to the host- Anyone on the network path reads the session token out of that request and replays it
httponly=Truekeeps the cookie away from page scripts; it says nothing about the transport
Django Without SESSION_COOKIE_SECURE
# 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
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 = Falselets Django send the session cookie over plain HTTP, where it can be interceptedCSRF_COOKIE_SECURE = Falseexposes the CSRF token the same wayDEBUG = Falseand a realALLOWED_HOSTSmark this as a production settings file, so these are the deployed values
FastAPI Cookie Without Secure Flag
# 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
secureparameter is left at itsFalsedefault, so the browser sendssession_tokenover 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"andhttponly=Truelimit who can send or read the cookie, not what it travels over
Remember-Me Cookie Without Secure Flag
# 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
Custom Auth Cookie Without Proper Flags
# 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 ofdocument.cookie - No
samesite, so nothing in the code states the cookie's cross-site behaviour and it falls to whatever the browser defaults to
OAuth State Cookie Without Secure Flag
# 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
statevalue 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
statelets 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
statethe check now approves. - Without
httpOnlyany XSS can read it too, and withoutsecurethe 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()omitsSecureunless you passsecure=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 omitssecure, sosession_idgoes 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
Flask Cookie With All Security Flags
# 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=Truekeeps the cookie off plaintext requests,httponly=Truekeeps it out ofdocument.cookie, andsamesite='Strict'adds CSRF defense-in-depth.- The
SESSION_COOKIE_*config covers Flask's ownsessioncookie; theset_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.
Django With Proper Cookie Security
# 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.
JWT Cookie With Proper Security
# 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.
OAuth State Cookie With Security
# 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-CookiecarriesSecure,HttpOnlyand aSameSitevalue, 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.
Securegoverns what the browser transmits, not what the server writes. - Exercise the deployed ingress, not a local HTTPS run: browsers special-case
localhostand acceptSecurecookies there over plain HTTP, so a passing local test says nothing about production.
Common Pitfalls
- Setting
SESSION_COOKIE_SECURE = Truein Django and treating the finding as resolved - it only covers the framework's session cookie (andCSRF_COOKIE_SECUREseparately covers the CSRF cookie); any cookie set manually withHttpResponse.set_cookie()in a view still needssecure=Truepassed explicitly. - Testing a
secure=Truecookie only againstlocalhostover plain HTTP during development - most browsers special-caselocalhostand still acceptSecurecookies there, so a passing local test doesn't verify behavior on a real HTTPS deployment behind a reverse proxy. - Enabling
SECURE_SSL_REDIRECTwithout configuringSECURE_PROXY_SSL_HEADERbehind 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 thatSESSION_COOKIE_SECUREis meant to rely on.
Additional Resources
- CWE-614: Sensitive Cookie in HTTPS Session Without 'Secure' Attribute
- Django Security Settings
- FastAPI Security Best Practices
- Flask Security Considerations
- OWASP Session Management Cheat Sheet
- Python
datetime-utcnow()is deprecated from Python 3.12; usedatetime.now(timezone.utc)