Skip to content

CWE-295: Improper Certificate Validation - Python

Overview

Improper certificate validation in Python occurs when applications disable or incorrectly implement SSL/TLS certificate verification when making HTTPS requests. This is commonly seen with the requests library (verify=False), urllib3 (cert_reqs='CERT_NONE'), or custom ssl context configurations. Disabling certificate validation defeats the purpose of HTTPS encryption by allowing man-in-the-middle (MITM) attacks where attackers can intercept, read, and modify encrypted traffic. Python's default behavior is secure - problems arise when developers explicitly disable validation for development/testing and forget to re-enable it for production.

Primary Defence: Never use verify=False in production; instead, use verify=True (default) or specify custom CA bundle path with verify='/path/to/ca-bundle.crt' for internal CAs.

Common Vulnerable Patterns

requests with verify=False

import requests

# VULNERABLE - Certificate validation completely disabled
response = requests.get('https://api.example.com/data', verify=False)

# urllib3 warnings suppressed - even worse!
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
response = requests.get('https://api.example.com/data', verify=False)

# Attacker on network can intercept this request

Why this is vulnerable: verify=False completely disables SSL/TLS certificate validation, allowing attackers on the network to perform man-in-the-middle attacks by presenting self-signed or fraudulent certificates, intercepting sensitive data like credentials, API keys, and user information transmitted over supposedly secure HTTPS connections.

urllib3 with cert_reqs='CERT_NONE'

import urllib3

# VULNERABLE - No certificate validation
http = urllib3.PoolManager(cert_reqs='CERT_NONE')
response = http.request('GET', 'https://api.example.com/data')

# Also vulnerable
http = urllib3.PoolManager(assert_hostname=False, cert_reqs='CERT_NONE')

Why this is vulnerable: cert_reqs='CERT_NONE' instructs urllib3 to skip all certificate validation checks including chain verification and hostname matching, making MITM attacks trivial as attackers can present any certificate and intercept all traffic without detection.

ssl.create_default_context() with check_hostname=False

import ssl
import urllib.request

# VULNERABLE - Hostname verification disabled
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE

response = urllib.request.urlopen('https://api.example.com/data', context=context)

# Certificate not validated, hostname not checked

Why this is vulnerable: Even though using ssl.create_default_context() which should be secure, explicitly setting check_hostname=False and verify_mode=ssl.CERT_NONE disables all validation, defeating HTTPS security and allowing attackers to intercept encrypted communications.

socket with Wrapped SSL (No Validation)

import socket
import ssl

# VULNERABLE - Raw SSL socket without validation
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
wrapped_socket = ssl.wrap_socket(sock)  # No certificate validation!

wrapped_socket.connect(('api.example.com', 443))
wrapped_socket.send(b'GET / HTTP/1.1\r\nHost: api.example.com\r\n\r\n')
data = wrapped_socket.recv(4096)

Why this is vulnerable: ssl.wrap_socket() without specifying cert_reqs or an SSL context performs no certificate validation by default, allowing attackers to intercept the connection with any certificate, making the encryption useless against man-in-the-middle attacks.

No cert validation performed

import socket
import ssl

# VULNERABLE - ssl.wrap_socket() without cert_reqs defaults to no validation
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
wrapped = ssl.wrap_socket(sock)
wrapped.connect(('api.example.com', 443))

# Also vulnerable: explicitly setting no validation
wrapped = ssl.wrap_socket(
    sock,
    cert_reqs=ssl.CERT_NONE  # No certificate validation
)
wrapped.connect(('api.example.com', 443))

# Send sensitive data over connection with no cert validation
wrapped.send(b'POST /api/login HTTP/1.1\r\n...\r\n')

Why this is vulnerable: ssl.wrap_socket() without the cert_reqs parameter defaults to ssl.CERT_NONE, performing no certificate validation whatsoever. This means the connection will accept any certificate - self-signed, expired, revoked, or issued for a different domain - making man-in-the-middle attacks trivial. An attacker on the network can intercept the connection, present their own certificate, and decrypt all traffic including passwords, API keys, and sensitive data. Even though the connection uses SSL/TLS encryption, without certificate validation there's no authentication of the server's identity, defeating the core security purpose of HTTPS. This pattern is particularly dangerous because it "looks" secure (using SSL), but provides no protection against active attackers.

httpx with verify=False

import httpx

# VULNERABLE - Modern HTTP client with validation disabled
async with httpx.AsyncClient(verify=False) as client:
    response = await client.get('https://api.example.com/data')

# Synchronous version also vulnerable
client = httpx.Client(verify=False)
response = client.get('https://api.example.com/data')

Why this is vulnerable: Same as requests - verify=False disables certificate validation.

aiohttp without SSL Verification

import aiohttp
import ssl

# VULNERABLE - aiohttp with disabled SSL verification
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE

async with aiohttp.ClientSession() as session:
    async with session.get('https://api.example.com/data', ssl=ssl_context) as response:
        data = await response.text()

# Also vulnerable: ssl=False
async with session.get('https://api.example.com/data', ssl=False) as response:
    data = await response.text()

Why this is vulnerable: Custom SSL context disables validation. ssl=False bypasses all SSL checks.

Custom Hostname Verification Bypass

import ssl
import certifi
import urllib3

# VULNERABLE - Custom match_hostname that accepts anything
def insecure_match_hostname(cert, hostname):
    return True  # Always accepts - DANGEROUS

# Used with urllib3
class InsecureHTTPSConnectionPool(urllib3.HTTPSConnectionPool):
    def _validate_conn(self, conn):
        super()._validate_conn(conn)
        # Override with insecure validation
        conn.assert_hostname = False

# Bypasses hostname verification

Why this is vulnerable: Custom validation logic that always returns True defeats the security mechanism.

Environment Variable Control

import os
import requests

# VULNERABLE - Allowing env var to disable validation
verify_ssl = os.getenv('VERIFY_SSL', 'true').lower() == 'true'

response = requests.get('https://api.example.com/data', verify=verify_ssl)

# Attacker can set VERIFY_SSL=false to disable validation

Why this is vulnerable: External control over security settings. Attacker with environment access can disable validation.

Secure Patterns

SECURE: requests with Default Validation

import requests

# SECURE - Default certificate validation (verify=True is default)
response = requests.get('https://api.example.com/data')

# Explicit verification (recommended for clarity)
response = requests.get('https://api.example.com/data', verify=True)

# Certificate validated against system CA bundle
# Hostname verified to match certificate

Why this works: The requests library's default verify=True performs comprehensive certificate validation including verifying the certificate chain against the system's Certificate Authority (CA) bundle, checking certificate expiration dates, and validating that the certificate's Common Name (CN) or Subject Alternative Name (SAN) matches the requested hostname. This three-layer validation prevents man-in-the-middle attacks by ensuring the certificate was issued by a trusted CA (preventing forged certificates), is currently valid (preventing replay attacks with expired certificates), and actually belongs to the intended server (preventing attacks where a valid certificate for a different domain is presented). The system CA bundle typically contains root certificates from well-known CAs like Let's Encrypt, DigiCert, and others, maintained by the operating system or the certifi package. Making verify=True explicit in code (even though it's the default) documents security intent and prevents accidental changes.

SECURE: requests with Custom CA Bundle

import requests

# SECURE - Verify against specific CA bundle
response = requests.get(
    'https://api.example.com/data',
    verify='/path/to/ca-bundle.crt'
)

# Or use certifi for Mozilla's CA bundle
import certifi

response = requests.get(
    'https://api.example.com/data',
    verify=certifi.where()
)

Why this works: Specifying a custom CA bundle path via the verify parameter allows validation against internal or private Certificate Authorities that aren't included in the system's default trust store, which is essential for corporate environments with internal PKI infrastructure. The certifi package provides Mozilla's curated CA bundle, which is regularly updated with trusted root certificates and is more consistent across platforms than relying on system trust stores (which vary between Windows, macOS, and Linux distributions). When connecting to internal services signed by a corporate CA, pointing verify to the internal CA certificate file (in PEM format) enables secure HTTPS while maintaining full certificate validation. This approach is superior to verify=False because it maintains the security properties of HTTPS (authentication, encryption, integrity) while accommodating non-public CAs. The validation process is identical to the default behavior - verifying the chain of trust, expiration, and hostname - but uses the specified CA bundle instead of the system default.

SECURE: urllib3 with Proper Validation

import urllib3
import certifi

# SECURE - Proper certificate validation
http = urllib3.PoolManager(
    cert_reqs='CERT_REQUIRED',
    ca_certs=certifi.where()
)

response = http.request('GET', 'https://api.example.com/data')

# Certificate fully validated

Why this works: The urllib3 library's cert_reqs='CERT_REQUIRED' parameter enforces strict certificate validation by requiring the server to present a valid certificate that chains to a trusted CA, with ca_certs=certifi.where() specifying which CA certificates are considered trustworthy. Unlike CERT_NONE (no validation) or CERT_OPTIONAL (validation attempted but connection proceeds even if it fails), CERT_REQUIRED causes the connection to fail if the certificate cannot be validated, implementing a "fail secure" approach. The PoolManager handles connection pooling and reuse, with the certificate validation occurring during the initial TLS handshake for each new connection. Hostname verification happens automatically when cert_reqs='CERT_REQUIRED' is set, matching the requested hostname against the certificate's CN and SAN fields. This configuration is equivalent to requests.get(verify=certifi.where()) but provides more direct control over the underlying urllib3 connection pool, which is useful when you need custom connection management or are using urllib3 directly rather than through the higher-level requests library.

SECURE: ssl.create_default_context() with Validation

import ssl
import urllib.request

# SECURE - Default SSL context (validation enabled)
context = ssl.create_default_context()

# Explicitly ensure validation (default, but clear intent)
context.check_hostname = True
context.verify_mode = ssl.CERT_REQUIRED

response = urllib.request.urlopen('https://api.example.com/data', context=context)

Why this works: Python's ssl.create_default_context() function returns an SSLContext object pre-configured with security best practices including verify_mode=ssl.CERT_REQUIRED (require valid certificate), check_hostname=True (verify hostname matches certificate), and a default set of trusted CA certificates loaded from the system's certificate store. This function was introduced in Python 3.4 specifically to provide a secure-by-default SSL configuration, preventing common mistakes like forgetting to enable hostname verification or certificate validation. The explicit setting of check_hostname=True and verify_mode=ssl.CERT_REQUIRED (even though they're defaults) serves as documentation and defense-in-depth, ensuring these critical security settings remain enabled even if future Python versions change defaults. When passed to urllib.request.urlopen(), this context is used for the TLS handshake, automatically rejecting connections with invalid, expired, self-signed, or hostname-mismatched certificates. This lower-level approach gives more control than requests but requires more careful handling of the SSL context.

SECURE: httpx with Validation

import httpx
import certifi

# SECURE - httpx with default validation
async with httpx.AsyncClient() as client:
    response = await client.get('https://api.example.com/data')

# Or with specific CA bundle
client = httpx.Client(verify=certifi.where())
response = client.get('https://api.example.com/data')

Why this works: The httpx library is a modern HTTP client designed as a successor to requests, with certificate validation enabled by default through verify=True, providing the same security guarantees as requests (certificate chain validation, expiration checking, hostname verification) but with additional features like async support and HTTP/2. The AsyncClient() constructor without arguments uses the default secure configuration, automatically loading the system's CA bundle (or certifi if installed) for certificate validation. Explicitly passing verify=certifi.where() to the synchronous Client() ensures validation uses Mozilla's maintained CA bundle, which provides consistency across platforms and regular updates to trusted root certificates. The httpx library's design principle is "secure by default," making it difficult to accidentally create insecure configurations - you must explicitly pass verify=False to disable validation, which is intentionally verbose to discourage the practice. The library also provides better error messages than requests when certificate validation fails, helping developers diagnose issues without resorting to disabling validation.

SECURE: aiohttp with Proper SSL

import aiohttp
import ssl
import certifi

# SECURE - aiohttp with proper SSL context
ssl_context = ssl.create_default_context(cafile=certifi.where())

async with aiohttp.ClientSession() as session:
    async with session.get('https://api.example.com/data', ssl=ssl_context) as response:
        data = await response.text()

# Or use default (recommended)
async with aiohttp.ClientSession() as session:
    async with session.get('https://api.example.com/data') as response:
        data = await response.text()

Why this works: The aiohttp library for async HTTP requires explicit SSL context configuration, and using ssl.create_default_context(cafile=certifi.where()) ensures secure defaults (certificate validation, hostname checking, trusted CA bundle) are applied to async connections. The cafile parameter loads the CA bundle from certifi, providing consistent certificate validation across platforms. Passing this ssl_context to session.get() applies the validation to that specific request, while omitting the ssl parameter (using the default behavior) also performs validation using the system's default SSL context. The ClientSession manages connection pooling for async requests, with the SSL context applied during the initial TLS handshake for each connection in the pool. This async pattern is essential for high-performance applications making many concurrent HTTPS requests, and maintaining certificate validation in async code is critical because the performance benefits of async I/O would be meaningless if the connections are vulnerable to MITM attacks. The async with context managers ensure proper cleanup of connections and sessions even if errors occur.

SECURE: Certificate Pinning for High Security

import ssl
import hashlib
import requests
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.poolmanager import PoolManager

# SECURE - Certificate pinning
class PinnedHTTPSAdapter(HTTPAdapter):
    def __init__(self, pin_sha256, *args, **kwargs):
        self.pin_sha256 = pin_sha256
        super().__init__(*args, **kwargs)

    def init_poolmanager(self, *args, **kwargs):
        context = ssl.create_default_context()
        # Set up pinning callback
        context.check_hostname = True
        context.verify_mode = ssl.CERT_REQUIRED
        kwargs['ssl_context'] = context
        return super().init_poolmanager(*args, **kwargs)

# Pin expected certificate's SHA-256 fingerprint
EXPECTED_PIN = 'sha256_hash_of_expected_certificate'

session = requests.Session()
session.mount('https://', PinnedHTTPSAdapter(EXPECTED_PIN))

# Only accepts pinned certificate
response = session.get('https://api.example.com/data')

Why this works: Certificate pinning adds an additional security layer beyond standard certificate validation by accepting only a specific certificate (or public key hash), protecting against attacks where a Certificate Authority is compromised or coerced into issuing fraudulent certificates for your domain. The PinnedHTTPSAdapter extends requests.HTTPAdapter to inject custom SSL context configuration into the connection pool, with the pin_sha256 parameter storing the expected SHA-256 hash of the legitimate certificate's public key. During the TLS handshake, the adapter can verify the presented certificate matches the pinned hash before completing the connection. While this example shows the structure, a complete implementation would need to extract and compare the certificate hash in a callback. Certificate pinning is recommended for high-security applications (banking, healthcare, government) communicating with a small number of known servers, but it requires operational overhead to update pins when certificates rotate. The session.mount() call applies this custom adapter to all HTTPS requests through that session, ensuring consistent pinning enforcement.

SECURE: Validation with Custom CA for Internal Services

import requests
import ssl

# SECURE - Internal CA for corporate network
INTERNAL_CA_PATH = '/etc/ssl/certs/internal-ca.crt'

# For requests library
response = requests.get(
    'https://internal-api.company.local/data',
    verify=INTERNAL_CA_PATH
)

# For urllib with ssl context
context = ssl.create_default_context(cafile=INTERNAL_CA_PATH)
context.check_hostname = True
context.verify_mode = ssl.CERT_REQUIRED

Why this works: Many corporate and internal networks use private Certificate Authorities to sign certificates for internal services, and specifying the internal CA certificate path via verify=INTERNAL_CA_PATH (for requests) or cafile=INTERNAL_CA_PATH (for ssl.create_default_context()) enables secure HTTPS connections to these services without disabling validation. The internal CA certificate (typically in PEM format) becomes the trust anchor for validating the certificate chain presented by internal servers, performing the same validation checks (chain of trust, expiration, hostname) as public CAs but rooted in the organization's private CA. This approach is dramatically more secure than verify=False because it maintains authentication, encryption, and integrity properties of HTTPS while accommodating the internal PKI. The check_hostname=True and verify_mode=ssl.CERT_REQUIRED settings ensure hostname verification still occurs, preventing an attacker from presenting a different valid internal certificate. Organizations should distribute the internal CA certificate securely to client systems and applications, ideally installing it in the system trust store rather than bundling it with application code.

Key Security Functions

Validation Checker

import requests
import urllib3
import ssl

def check_ssl_config(session):
    """Check if SSL validation is properly configured"""
    if isinstance(session, requests.Session):
        # Check requests session
        for adapter in session.adapters.values():
            if hasattr(adapter, 'init_poolmanager'):
                # Check if verification disabled
                if getattr(adapter, 'verify', True) is False:
                    raise ValueError("SSL verification is disabled!")

    return True

def audit_ssl_context(context):
    """Audit SSL context configuration"""
    if not isinstance(context, ssl.SSLContext):
        raise TypeError("Invalid SSL context")

    issues = []

    if context.verify_mode == ssl.CERT_NONE:
        issues.append("Certificate verification disabled (CERT_NONE)")

    if not context.check_hostname:
        issues.append("Hostname verification disabled")

    if context.verify_mode != ssl.CERT_REQUIRED:
        issues.append(f"Weak verification mode: {context.verify_mode}")

    if issues:
        raise ValueError(f"SSL configuration issues: {', '.join(issues)}")

    return True

Safe Request Wrapper

import requests
import certifi
from typing import Optional

def safe_https_request(url: str, method: str = 'GET', **kwargs) -> requests.Response:
    """
    Make HTTPS request with enforced certificate validation

    Args:
        url: Target URL (must be HTTPS)
        method: HTTP method
        **kwargs: Additional arguments for requests

    Returns:
        Response object

    Raises:
        ValueError: If attempting to disable validation or using HTTP
    """
    # Enforce HTTPS
    if not url.startswith('https://'):
        raise ValueError("Only HTTPS URLs allowed")

    # Prevent disabling validation
    if 'verify' in kwargs and kwargs['verify'] is False:
        raise ValueError("Cannot disable certificate validation")

    # Use certifi CA bundle if not specified
    if 'verify' not in kwargs:
        kwargs['verify'] = certifi.where()

    # Make request
    response = requests.request(method, url, **kwargs)

    # Verify connection used TLS
    if not response.url.startswith('https://'):
        raise ValueError("Connection did not use HTTPS")

    return response

# Usage
response = safe_https_request('https://api.example.com/data')

Certificate Information Extractor

import ssl
import socket
from datetime import datetime

def get_certificate_info(hostname: str, port: int = 443) -> dict:
    """
    Extract certificate information from server

    Args:
        hostname: Server hostname
        port: Server port (default 443)

    Returns:
        Dictionary with certificate details
    """
    context = ssl.create_default_context()

    with socket.create_connection((hostname, port)) as sock:
        with context.wrap_socket(sock, server_hostname=hostname) as ssock:
            cert = ssock.getpeercert()

            # Extract key information
            info = {
                'subject': dict(x[0] for x in cert['subject']),
                'issuer': dict(x[0] for x in cert['issuer']),
                'version': cert['version'],
                'serial_number': cert['serialNumber'],
                'not_before': cert['notBefore'],
                'not_after': cert['notAfter'],
                'subject_alt_names': cert.get('subjectAltName', [])
            }

            # Check expiration
            not_after = datetime.strptime(cert['notAfter'], '%b %d %H:%M:%S %Y %Z')
            days_until_expiry = (not_after - datetime.now()).days
            info['days_until_expiry'] = days_until_expiry
            info['is_expired'] = days_until_expiry < 0

            return info

# Usage
cert_info = get_certificate_info('api.example.com')
print(f"Certificate expires in {cert_info['days_until_expiry']} days")

Verification

After implementing the recommended secure patterns, verify the fix through multiple approaches:

  • Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
  • Code review: Confirm all instances use the secure pattern (parameterized queries, safe APIs, proper encoding) with no string concatenation or unsafe operations
  • Static analysis: Use security scanners to verify no new vulnerabilities exist and the original finding is resolved
  • Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
  • Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
  • Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
  • Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
  • Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced

Analysis Steps

  1. Locate the certificate validation bypass:
# Line 34 in api/client.py
def fetch_user_data(user_id):
    api_url = f'https://api.internal.company.com/users/{user_id}'
    response = requests.get(api_url, verify=False)  # VULNERABLE
    return response.json()
  1. Identify why validation was disabled:
  • Development workaround for self-signed certificate?
  • Internal CA not in system trust store?
  • "Quick fix" that made it to production?
  1. Assess the risk:
  • API returns sensitive user data
  • Connection over HTTPS but validation disabled
  • Vulnerable to MITM on corporate network
  • Impact: High (data exposure)
  1. Determine proper fix:
  • Internal API likely uses internal CA
  • Need to add internal CA to trust store
  • Or specify CA bundle in verify parameter

Remediation Steps

Step 1: Identify the proper CA certificate

# Get the certificate from the server
openssl s_client -connect api.internal.company.com:443 -showcerts

# Extract CA certificate
# Save to /etc/ssl/certs/internal-ca.crt

Step 2: Fix the code

# BEFORE (Line 34 - vulnerable)
def fetch_user_data(user_id):
    api_url = f'https://api.internal.company.com/users/{user_id}'
    response = requests.get(api_url, verify=False)  # VULNERABLE
    return response.json()

# AFTER (fixed)
import os

# Path to internal CA certificate
INTERNAL_CA = os.getenv('INTERNAL_CA_CERT', '/etc/ssl/certs/internal-ca.crt')

def fetch_user_data(user_id):
    api_url = f'https://api.internal.company.com/users/{user_id}'
    response = requests.get(api_url, verify=INTERNAL_CA)  # SECURE
    return response.json()

Step 3: Add CA to system trust store (alternative)

# On Linux
sudo cp internal-ca.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates

# Now can use verify=True (system default)

Step 4: Re-scan

bandit -r api/client.py
# Should show no B501 findings

Verification

After remediation:

  • No verify=False in production code
  • No ssl.CERT_NONE usage
  • No check_hostname=False settings
  • Using proper CA bundle for internal services
  • Scanner re-scan shows finding resolved
  • Integration tests pass with validation enabled
  • Certificate expiration monitoring in place

Security Checklist

  • Never use verify=False with requests/httpx
  • Never use ssl.CERT_NONE for cert_reqs
  • Never set check_hostname=False
  • Never suppress urllib3 SSL warnings
  • Use default SSL context (ssl.create_default_context())
  • Use verify=True or specify CA bundle path
  • For internal CAs, add to system trust store or specify path
  • Remove all development SSL bypass code before production
  • Use certificate pinning for high-security scenarios
  • Monitor certificate expiration dates
  • Run Bandit to detect validation bypasses
  • Test with badssl.com for various certificate issues
  • Never allow environment variables to control SSL verification

Additional Resources