Skip to content

CWE-522: Insufficiently Protected Credentials - Python

Overview

Insufficiently Protected Credentials in Python applications occurs when passwords, API keys, tokens, or other authentication secrets are stored in plaintext, weakly encrypted, hardcoded in source code, or transmitted insecurely. Python has maintained cryptographic libraries for this work - bcrypt, Argon2, and cryptography - but they have to be chosen and configured deliberately.

Primary Defence: Use bcrypt or argon2-cffi for password hashing with appropriate cost factors, and never store passwords in plaintext.

Common Vulnerable Patterns

Storing Passwords in Plaintext

# VULNERABLE - Plaintext password storage
from flask import Flask, request
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
db = SQLAlchemy(app)

class User(db.Model):
    username = db.Column(db.String(80), unique=True, nullable=False)
    password = db.Column(db.String(120), nullable=False)  # Plaintext!

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

    # Stores password directly without hashing!
    user = User(username=username, password=password)
    db.session.add(user)
    db.session.commit()

    return {'status': 'success'}

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

    user = User.query.filter_by(username=username).first()
    # Direct password comparison!
    if user and user.password == password:
        return {'token': generate_token(user)}
    return {'error': 'Invalid credentials'}, 401

Why this is vulnerable:

  • Database access reveals passwords immediately.
  • Password reuse turns one breach into many.

Hardcoded Credentials

# VULNERABLE - Credentials in source code
import psycopg2

# Hardcoded database credentials!
DB_HOST = "prod-database.company.com"
DB_USER = "admin"
DB_PASSWORD = "SuperSecret123!"  # NEVER DO THIS!
DB_NAME = "production"

def get_db_connection():
    return psycopg2.connect(
        host=DB_HOST,
        user=DB_USER,
        password=DB_PASSWORD,
        database=DB_NAME
    )

# Hardcoded API keys!
API_KEY = "sk-live-abc123def456ghi789"
SECRET_KEY = "my-secret-key-12345"

app = Flask(__name__)
app.config['SECRET_KEY'] = SECRET_KEY  # Hardcoded!

Why this is vulnerable:

  • Secrets in source or repo history are easily leaked.
  • Rotation requires code changes and redeploys.

Weak Password Hashing

# VULNERABLE - Using weak hash functions
import hashlib

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

    # MD5 and SHA1 are broken for password hashing!
    password_hash = hashlib.md5(password.encode()).hexdigest()
    # or password_hash = hashlib.sha1(password.encode()).hexdigest()

    user = User(username=username, password_hash=password_hash)
    db.session.add(user)
    db.session.commit()

    return {'status': 'success'}

Why this is vulnerable:

  • Fast hashes make brute-force practical.
  • No salt or work factor enables rainbow tables.

Credentials in Environment Variables Without Protection

# VULNERABLE - .env file committed to git
# .env (checked into version control!)
DATABASE_URL=postgresql://admin:password123@localhost/mydb
API_KEY=sk-live-abc123def456
SECRET_KEY=django-insecure-development-key
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

The loader below is correct; the flaw is that the file it reads is tracked:

# settings.py
import os
from dotenv import load_dotenv

load_dotenv()  # Loads from .env

# If .env is in git, credentials are exposed!
DATABASE_URL = os.getenv('DATABASE_URL')
API_KEY = os.getenv('API_KEY')

Why this is vulnerable:

  • Secrets persist in git history and forks.
  • Public repo exposure is common and hard to fully undo.

Insecure JWT Token Generation

# VULNERABLE - Weak JWT implementation
import jwt
from datetime import datetime, timedelta, timezone

# Weak secret key!
JWT_SECRET = "secret"  # Too simple!
JWT_ALGORITHM = "HS256"

def create_token(user):
    payload = {
        'user_id': user.id,
        'password': user.password_hash,  # Including password hash in token!
        'exp': datetime.now(timezone.utc) + timedelta(days=365)  # Too long-lived!
    }
    # Weak secret makes tokens easy to forge
    return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)

Why this is vulnerable: Three separate defects compound. An HS256 secret of "secret" is recovered offline from a single captured token in seconds - no interaction with the server, no rate limit - and with it an attacker mints tokens for any user_id. A 365-day expiry means a token that leaks stays valid for a year, and JWTs are bearer credentials with no revocation unless one is built.

The payload is the part most often misread. A JWT is signed, not encrypted: the claims are base64url of plaintext, so anyone holding the token reads them. Putting password_hash in there publishes the hash to every client and to every log, proxy and browser history that records the token - which moves password cracking offline for whoever collects one.

Transmitting Credentials Over HTTP

# VULNERABLE - HTTP transmission
@app.route('/api/authenticate', methods=['POST'])
def authenticate():
    # Credentials sent over HTTP (not HTTPS)!
    username = request.json['username']
    password = request.json['password']

    # Even if hashed after receipt, transmission was insecure
    if verify_credentials(username, password):
        return {'token': generate_token()}
    return {'error': 'Invalid'}, 401

# No HTTPS enforcement
if __name__ == '__main__':
    app.run(debug=True)  # HTTP only!

Why this is vulnerable: Hashing on receipt protects the stored value, not the transmitted one - the password crossed the network in the request body and anyone on the path already has it. TLS is the only control that applies to this leg, and it has to be applied before the request is sent rather than after it arrives.

debug=True is a second, larger problem hiding in the deployment line. It enables the Werkzeug interactive debugger, which offers a Python console on any unhandled exception - remote code execution to whoever reaches it, guarded only by a PIN derived from host details that a directory-traversal or file-read bug can supply. It belongs behind an environment check that cannot be true in production, and the server itself belongs behind a TLS-terminating proxy.

Secure Patterns

Bcrypt Password Hashing

# SECURE - Proper bcrypt implementation
from flask import Flask, request, jsonify
from flask_sqlalchemy import SQLAlchemy
import bcrypt

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///users.db'
db = SQLAlchemy(app)

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)
    password_hash = db.Column(db.String(128), nullable=False)

# A real bcrypt hash at the same cost, used only to spend the same time on an
# unknown username. It has to be a genuine hash - checkpw() against b'' raises
# ValueError, and any short-circuit here restores the timing gap it exists to close.
DUMMY_HASH = b'$2b$12$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG'

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

    # Generate salt and hash password with bcrypt
    salt = bcrypt.gensalt(rounds=12)  # OWASP sets the floor at 10; raise it as far as
                                      # verification latency allows. 12 is a common choice.
    password_hash = bcrypt.hashpw(password.encode('utf-8'), salt)

    user = User(
        username=username,
        password_hash=password_hash.decode('utf-8')  # Store as string
    )
    db.session.add(user)
    db.session.commit()

    return jsonify({'status': 'success', 'user_id': user.id})

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

    user = User.query.filter_by(username=username).first()

    if not user:
        # Do the same work before failing. Returning here answers in microseconds
        # where a real user costs ~250ms, and that gap enumerates every username.
        bcrypt.checkpw(password.encode('utf-8'), DUMMY_HASH)
        return jsonify({'error': 'Invalid credentials'}), 401

    # Verify password using bcrypt
    if bcrypt.checkpw(password.encode('utf-8'), user.password_hash.encode('utf-8')):
        token = generate_secure_token(user.id)
        return jsonify({'token': token})

    return jsonify({'error': 'Invalid credentials'}), 401

Why this works:

  • Bcrypt uses per-password salts and a tunable cost to make offline cracking expensive.
  • checkpw() verifies safely and stored hashes are in standard formats.
  • Checking an unknown username against a dummy hash removes the response-time difference that otherwise enumerates accounts. Every login now pays the full hashing cost, so rate-limit the endpoint.

Django Password Hashing

# SECURE - Django built-in password hashing
# settings.py
PASSWORD_HASHERS = [
    'django.contrib.auth.hashers.Argon2PasswordHasher',  # Recommended
    'django.contrib.auth.hashers.PBKDF2PasswordHasher',  # Fallback
    'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
    'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
]

# Install argon2: pip install django[argon2]

# views.py
from django.contrib.auth.models import User
from django.contrib.auth import authenticate, login
from django.contrib.auth.hashers import make_password, check_password
from django.http import JsonResponse
import json

# No csrf_exempt: both views create or rely on a cookie-backed session, so Django's
# CSRF middleware has to stay in force. Send the CSRF token from the client (the
# X-CSRFToken header for fetch/XHR) rather than turning the check off.
def register(request):
    if request.method == 'POST':
        data = json.loads(request.body)
        username = data.get('username')
        password = data.get('password')

        # Django automatically uses configured hasher
        user = User.objects.create_user(
            username=username,
            password=password  # Automatically hashed!
        )

        return JsonResponse({'status': 'success', 'user_id': user.id})

def user_login(request):
    if request.method == 'POST':
        data = json.loads(request.body)
        username = data.get('username')
        password = data.get('password')

        # Authenticate handles secure password checking
        user = authenticate(username=username, password=password)

        if user is not None:
            login(request, user)
            return JsonResponse({'status': 'success'})

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

Why this works:

  • Django hashes passwords automatically with modern hashers and supports upgrades.
  • authenticate() verifies against configured hashers without custom logic.
  • login() issues a session cookie, so these endpoints are CSRF-relevant and keep the middleware's protection. Exempting them would let another origin submit a login and fix a session on the victim's browser - a separate weakness from the one being fixed here. A pure token API that issues a bearer token and sets no cookie is a different case, and can use @csrf_exempt deliberately.

Secrets Management with HashiCorp Vault

# SECURE - HashiCorp Vault integration
import hvac
import os

class VaultSecrets:
    def __init__(self):
        # Vault address and token from environment (not in code)
        vault_url = os.getenv('VAULT_ADDR', 'http://localhost:8200')
        vault_token = os.getenv('VAULT_TOKEN')

        self.client = hvac.Client(url=vault_url, token=vault_token)

        if not self.client.is_authenticated():
            raise Exception('Failed to authenticate with Vault')

    def get_secret(self, path):
        """Retrieve secret from Vault"""
        try:
            secret = self.client.secrets.kv.v2.read_secret_version(path=path)
            return secret['data']['data']
        except Exception as e:
            # Log error but don't expose vault details
            print(f'Failed to retrieve secret: {path}')
            raise

    def get_database_credentials(self):
        """Get database credentials from Vault"""
        creds = self.get_secret('database/postgresql')
        return {
            'host': creds['host'],
            'port': creds['port'],
            'user': creds['username'],
            'password': creds['password'],
            'database': creds['database']
        }

# Usage in application
vault = VaultSecrets()
db_creds = vault.get_database_credentials()

# Use credentials without hardcoding
import psycopg2

conn = psycopg2.connect(
    host=db_creds['host'],
    port=db_creds['port'],
    user=db_creds['user'],
    password=db_creds['password'],
    database=db_creds['database']
)

Why this works:

  • Secrets are stored centrally and fetched at runtime, encrypted at rest.
  • Policies and short-lived tokens enable least privilege and rotation.

AWS Secrets Manager Integration

# SECURE - AWS Secrets Manager
import boto3
import json
from botocore.exceptions import ClientError

class AWSSecretsManager:
    def __init__(self, region_name='us-east-1'):
        self.client = boto3.client('secretsmanager', region_name=region_name)

    def get_secret(self, secret_name):
        """Retrieve secret from AWS Secrets Manager"""
        try:
            response = self.client.get_secret_value(SecretId=secret_name)

            if 'SecretString' in response:
                return json.loads(response['SecretString'])
            else:
                # Binary secrets
                return response['SecretBinary']
        except ClientError as e:
            if e.response['Error']['Code'] == 'ResourceNotFoundException':
                print(f'Secret {secret_name} not found')
            elif e.response['Error']['Code'] == 'InvalidRequestException':
                print(f'Invalid request for secret {secret_name}')
            elif e.response['Error']['Code'] == 'InvalidParameterException':
                print(f'Invalid parameter for secret {secret_name}')
            raise

# Usage
secrets = AWSSecretsManager(region_name='us-east-1')

# Get database credentials
db_secret = secrets.get_secret('production/database')
DATABASE_URL = f"postgresql://{db_secret['username']}:{db_secret['password']}@{db_secret['host']}/{db_secret['database']}"

# Get API keys
api_secret = secrets.get_secret('production/api-keys')
STRIPE_API_KEY = api_secret['stripe_key']
SENDGRID_API_KEY = api_secret['sendgrid_key']

Why this works:

  • Secrets are encrypted with KMS and accessed at runtime via IAM roles.
  • Rotation and CloudTrail logging reduce exposure and support auditing.

Secure JWT Implementation

# SECURE - Proper JWT token handling
from datetime import datetime, timedelta, timezone
import jwt
import secrets

# Generate strong secret key
def generate_secret_key():
    """Generate cryptographically secure secret key"""
    return secrets.token_urlsafe(64)

# Store in secrets manager, not in code. Separate keys per token class - a refresh
# token signed with the refresh key cannot be presented as an access token.
JWT_ACCESS_SECRET = vault.get_secret('jwt/access-key')['key']
JWT_REFRESH_SECRET = vault.get_secret('jwt/refresh-key')['key']
JWT_ALGORITHM = 'HS256'
JWT_ISSUER = 'https://api.example.com'
JWT_AUDIENCE = 'example-app'
ACCESS_TOKEN_LIFETIME = timedelta(minutes=15)  # Short-lived
REFRESH_TOKEN_LIFETIME = timedelta(days=30)

def create_access_token(user_id: int, expires_delta: timedelta = ACCESS_TOKEN_LIFETIME):
    """Create a short-lived access token"""
    now = datetime.now(timezone.utc)

    # Minimal claims - don't include sensitive data
    payload = {
        'user_id': user_id,
        'iss': JWT_ISSUER,
        'aud': JWT_AUDIENCE,
        'exp': now + expires_delta,
        'iat': now,  # Issued at
        'jti': secrets.token_urlsafe(16),  # JWT ID a revocation list would key on
        'type': 'access'
    }

    return jwt.encode(payload, JWT_ACCESS_SECRET, algorithm=JWT_ALGORITHM)

def create_refresh_token(user_id: int):
    """Create a long-lived refresh token, accepted only at the refresh endpoint"""
    now = datetime.now(timezone.utc)

    payload = {
        'user_id': user_id,
        'iss': JWT_ISSUER,
        'aud': JWT_AUDIENCE,
        'exp': now + REFRESH_TOKEN_LIFETIME,
        'iat': now,
        'jti': secrets.token_urlsafe(16),
        'type': 'refresh'
    }

    return jwt.encode(payload, JWT_REFRESH_SECRET, algorithm=JWT_ALGORITHM)

def _decode(token: str, secret: str, expected_type: str):
    try:
        payload = jwt.decode(
            token,
            secret,
            algorithms=[JWT_ALGORITHM],  # Never let the token's own alg header choose
            issuer=JWT_ISSUER,           # PyJWT only checks these when you pass them
            audience=JWT_AUDIENCE,
            options={'verify_exp': True,
                     'require': ['exp', 'iat', 'iss', 'aud', 'type']}
        )
    except jwt.ExpiredSignatureError:
        raise ValueError('Token has expired')
    except jwt.InvalidTokenError:
        raise ValueError('Invalid token')

    if payload['type'] != expected_type:
        raise ValueError('Invalid token')

    return payload

def verify_access_token(token: str):
    """Used by request authentication - a refresh token must not pass here"""
    return _decode(token, JWT_ACCESS_SECRET, 'access')

def verify_refresh_token(token: str):
    """Used only by the token refresh endpoint"""
    return _decode(token, JWT_REFRESH_SECRET, 'refresh')

Why this works:

  • Strong secrets and short-lived access tokens reduce blast radius.
  • Access and refresh tokens are signed with different keys and carry a type claim that verification requires, so a 30-day refresh token handed to verify_access_token() fails signature verification instead of authenticating the request for the next month. A type check against one shared key would also work, but then the check is the only thing separating the two token classes, and a call site that omits it accepts either.
  • Passing algorithms=[...] explicitly stops the token's own alg header from choosing the verification algorithm.
  • issuer= and audience= have to be passed to jwt.decode() to be checked at all. PyJWT ignores an iss or aud claim it was not asked about, so a token carrying them is not the same as a token validated against them, and listing them in require only asserts they are present. Both together is what rejects a correctly-signed token minted for a different service.

HTTPS Enforcement

# SECURE - Force HTTPS in Flask
from flask import Flask
from flask_talisman import Talisman
from werkzeug.middleware.proxy_fix import ProxyFix

app = Flask(__name__)

# TLS terminates at the proxy, so the request Flask sees is plain HTTP. Without this,
# request.is_secure is always False and Talisman redirects every request forever.
# The counts must match the number of proxies you actually run behind - trusting a
# header from an untrusted hop lets a client claim its own scheme and address.
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_for=1, x_host=1)

# Enforce HTTPS with Flask-Talisman. force_https already issues the redirect, so no
# additional before_request handler is needed - a second one only adds a way to
# disagree with the first.
Talisman(app,
         force_https=True,
         strict_transport_security=True,
         strict_transport_security_max_age=31536000,  # 1 year
         content_security_policy=None)

# Run under a production WSGI server behind a TLS-terminating proxy, not app.run():
#   gunicorn --workers 4 --bind 127.0.0.1:8000 app:app
# app.run() is the Werkzeug development server and is not built to face a network,
# whether or not you hand it an ssl_context.

Why this works:

  • TLS encrypts credentials in transit.
  • HSTS and redirects ensure HTTP is not accepted in production.
  • force_https covers the redirect and ProxyFix is what makes the check see the client's real scheme. Getting the second one wrong is the usual cause of the redirect loop that leads someone to disable the first.

FastAPI with OAuth2 and Password Hashing

Library note: passlib has had no release since 1.7.4 in 2020, and its bcrypt backend has been drifting out of compatibility since. Measured on Python 3.13:

  • With bcrypt 4.1 to 4.x, hashing and verification still work. Passlib's version probe reads bcrypt.__about__, which no longer exists, but it traps the AttributeError and prints it as a (trapped) error reading bcrypt version traceback. Alarming in the logs, harmless in effect.
  • With bcrypt 5.0, the first CryptContext.hash() raises ValueError: password cannot be longer than 72 bytes. That is not the trapped error above - it comes from passlib's backend self-test, which probes with an over-length password that bcrypt 4 truncated silently and bcrypt 5 rejects. Nothing hashes after that, so this is the version that actually breaks.

For new code call bcrypt or argon2-cffi directly, or use a maintained replacement such as pwdlib. The CryptContext pattern below is shown because it is widespread in existing FastAPI codebases; keep it only while pinned to bcrypt < 5.0. Python 3.13 itself is fine - the crypt module it removed backs only passlib's os_crypt backend, which passlib falls back from.

# SECURE - FastAPI OAuth2 implementation
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from passlib.context import CryptContext
from pydantic import BaseModel
from datetime import datetime, timedelta, timezone
import jwt

app = FastAPI()

# Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

# OAuth2 scheme
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

# Secret from secure storage
SECRET_KEY = vault.get_secret('api/secret-key')['key']
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

class User(BaseModel):
    username: str
    email: str
    password_hash: str

def verify_password(plain_password: str, hashed_password: str) -> bool:
    """Verify password against hash"""
    return pwd_context.verify(plain_password, hashed_password)

def get_password_hash(password: str) -> str:
    """Hash password using bcrypt"""
    return pwd_context.hash(password)

def create_access_token(data: dict, expires_delta: timedelta = None):
    """Create JWT access token"""
    to_encode = data.copy()
    if expires_delta:
        expire = datetime.now(timezone.utc) + expires_delta
    else:
        expire = datetime.now(timezone.utc) + timedelta(minutes=15)

    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

@app.post("/register")
async def register(username: str, password: str, email: str):
    """Register new user with hashed password"""
    password_hash = get_password_hash(password)

    user = User(
        username=username,
        email=email,
        password_hash=password_hash
    )

    # Save to database
    # db.add(user)

    return {"status": "success", "username": username}

@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
    """Login and get access token"""
    # Get user from database
    user = get_user(form_data.username)

    # `not user or not verify_password(...)` would short-circuit, skipping the hash
    # entirely for an unknown username and answering far faster than for a real one.
    # dummy_verify() exists for exactly this and costs what a real verify costs.
    if user:
        password_ok = verify_password(form_data.password, user.password_hash)
    else:
        pwd_context.dummy_verify()
        password_ok = False

    if not password_ok:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )

    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(
        data={"sub": user.username},
        expires_delta=access_token_expires
    )

    return {"access_token": access_token, "token_type": "bearer"}

async def get_current_user(token: str = Depends(oauth2_scheme)):
    """Get current user from token"""
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username: str = payload.get("sub")
        if username is None:
            raise HTTPException(status_code=401, detail="Invalid token")
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="Token expired")
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="Invalid token")

    user = get_user(username)
    if user is None:
        raise HTTPException(status_code=401, detail="User not found")

    return user

@app.get("/users/me")
async def read_users_me(current_user: User = Depends(get_current_user)):
    """Protected endpoint requiring authentication"""
    return current_user

Why this works:

  • OAuth2 flow with Passlib bcrypt provides consistent hashing and token handling.
  • Dependency injection centralizes auth checks for protected routes.
  • CryptContext.dummy_verify() keeps the unknown-username path as expensive as the known one, so the response time does not disclose which accounts exist. If you call the plain bcrypt/argon2-cffi API instead, verify against a fixed real hash as the Flask example above does.

Testing

To verify credentials are properly protected:

  • Check password storage: Passwords in the database are hashed (bcrypt hashes start with $2b$, Argon2 with $argon2), not plaintext
  • Confirm salt usage: Identical passwords for different users produce different hashes
  • Review configuration: Settings files read from environment variables or a secret manager, with no hardcoded credentials
  • Test JWT tokens: Tokens expire, and carry the issuer and audience claims
  • Search source code: Grep for hardcoded secrets, API keys, and passwords
  • Test authentication flow: Login succeeds with correct credentials and fails with incorrect ones
  • Check .env files: .env is in .gitignore and never committed to version control
  • Use security scanners: bandit flags hardcoded credentials and weak cryptographic usage
# SECURE - Tests to verify credential protection
import pytest
import bcrypt
from app import app, db, User
# The token helpers from the Secure JWT Implementation section above
from auth_tokens import (
    create_access_token,
    create_refresh_token,
    verify_access_token,
    verify_refresh_token,
)

class TestCredentialSecurity:
    @pytest.fixture
    def client(self):
        app.config['TESTING'] = True
        app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
        with app.test_client() as client:
            with app.app_context():
                db.create_all()
            yield client

    def test_passwords_are_hashed(self, client):
        """Verify passwords are not stored in plaintext"""
        response = client.post('/register', json={
            'username': 'testuser',
            'password': 'MyPassword123!'
        })

        assert response.status_code == 200

        # Check database directly
        user = User.query.filter_by(username='testuser').first()

        # Password should not be stored in plaintext
        assert user.password_hash != 'MyPassword123!'

        # Password hash should look like bcrypt format
        assert user.password_hash.startswith('$2b$')

        # Verify password can be checked
        assert bcrypt.checkpw(
            'MyPassword123!'.encode('utf-8'),
            user.password_hash.encode('utf-8')
        )

    def test_password_hash_is_unique(self, client):
        """Verify same password produces different hashes (salted)"""
        password = 'SamePassword123!'

        client.post('/register', json={
            'username': 'user1',
            'password': password
        })

        client.post('/register', json={
            'username': 'user2',
            'password': password
        })

        user1 = User.query.filter_by(username='user1').first()
        user2 = User.query.filter_by(username='user2').first()

        # Same password should produce different hashes due to salt
        assert user1.password_hash != user2.password_hash

    def test_weak_passwords_rejected(self, client):
        """Verify password strength requirements.

        This asserts a control the /register handler above does not implement - it
        hashes whatever it is given. Add the check (a length floor plus a breached-
        password lookup, per NIST SP 800-63B) before expecting this to pass; a
        correctly hashed weak password is still a weak password.
        """
        weak_passwords = [
            '12345',
            'password',
            'abc',
            '11111111'
        ]

        for weak_pass in weak_passwords:
            response = client.post('/register', json={
                'username': f'user_{weak_pass}',
                'password': weak_pass
            })

            # Should reject weak passwords
            assert response.status_code == 400
            data = response.get_json()
            assert 'password' in data['error'].lower()

    def test_no_hardcoded_secrets(self):
        """Verify no hardcoded credentials in code"""
        import app as app_module
        import inspect

        source = inspect.getsource(app_module)

        # Check for common hardcoded patterns
        forbidden_patterns = [
            'password =',
            'secret_key =',
            'api_key =',
            'AWS_SECRET',
            'DB_PASSWORD'
        ]

        for pattern in forbidden_patterns:
            # Allow in comments or variable names, but not assignments
            lines = [line for line in source.split('\n') 
                    if pattern.lower() in line.lower() and '=' in line
                    and not line.strip().startswith('#')]

            assert len(lines) == 0, f'Found potential hardcoded secret: {pattern}'

    def test_access_tokens_expire_soon(self):
        """Verify access tokens expire within the configured lifetime"""
        import jwt
        from datetime import datetime, timedelta, timezone

        token = create_access_token(user_id=123)

        # Decode without verification to inspect the claims
        unverified = jwt.decode(token, options={'verify_signature': False})

        # Must have expiration claim
        assert 'exp' in unverified

        # fromtimestamp() needs the tz to return an aware datetime comparable to now()
        exp_time = datetime.fromtimestamp(unverified['exp'], tz=timezone.utc)
        now = datetime.now(timezone.utc)

        assert exp_time > now
        # Minutes, not days - the refresh token is the only long-lived credential here
        assert exp_time <= now + ACCESS_TOKEN_LIFETIME + timedelta(seconds=5)

    def test_refresh_token_is_not_accepted_as_an_access_token(self):
        """A refresh token presented for request authentication must be rejected"""
        import pytest

        refresh_token = create_refresh_token(user_id=123)

        # Wrong signing key and wrong type claim - either alone is enough to fail
        with pytest.raises(ValueError):
            verify_access_token(refresh_token)

        # And it is still accepted where it belongs
        assert verify_refresh_token(refresh_token)['user_id'] == 123

Additional Resources