CWE-316: Cleartext Storage of Sensitive Information in Memory - Python
Overview
Storing sensitive data (passwords, API keys, cryptographic keys) in memory as cleartext in Python exposes it to memory dumps, debuggers, and memory disclosure vulnerabilities. Python strings are immutable and cannot be overwritten in place, making secure handling of sensitive data challenging. Use bytearray for mutable secrets where APIs support it, clear them after use, and consider memory-locking libraries for high-risk secrets.
Primary Defence: Use bytearray for passwords with explicit clearing (for i in range(len(password)): password[i] = 0) in finally blocks where APIs allow it, implement context managers with @contextmanager for scoped cleanup, and use OS controls such as mlock() plus dump/swap restrictions for highly sensitive processes. Treat memfd_create() as RAM-backed file-descriptor storage, not as a memory-dump or swap protection by itself.
Common Vulnerable Patterns
Storing password as immutable string
import getpass
# VULNERABLE - String is immutable, stays in memory
def authenticate_user():
password = getpass.getpass("Enter password: ")
# Password remains in memory as immutable string
# Cannot be cleared, may appear in memory dumps
result = verify_password(password)
# String not cleared from memory
return result
Why this is vulnerable: str is immutable, so nothing in the language overwrites one; del password unbinds the name and CPython frees the object when its refcount reaches zero, which returns the memory to the allocator without zeroing it. The bytes stay in the arena until something else claims them.
getpass.getpass() is the right function for the prompt and cannot help here, because its return type is fixed - and there is no standard-library alternative that reads a password straight into a mutable buffer, so the str always exists first. bytearray is the only mutable text-like buffer the standard library offers, and a plain for i in range(len(buf)): buf[i] = 0 overwrites it in place with no ctypes involved. Copying into one still leaves the original str and the intermediate bytes from .encode() behind, so the honest description of that move is that it stops further copies rather than removing the first two.
Storing API keys as strings
import os
# VULNERABLE - API key persists in memory as string
class APIClient:
def __init__(self):
# Immutable string - cannot be cleared
self.api_key = os.environ.get('API_KEY')
self.secret = os.environ.get('API_SECRET')
def make_request(self, endpoint):
# API key visible in memory during entire object lifetime
headers = {'Authorization': f'Bearer {self.api_key}'}
return requests.get(endpoint, headers=headers)
Why this is vulnerable: The attribute keeps the value reachable for the life of the client, so it is resident in every core dump taken afterwards rather than only during a call.
os.environ is the part worth separating out. Python builds it from the process environment at import time, and those strings are never collected - so the key is in memory whether or not the client is constructed. On Linux the same values are also readable from /proc/self/environ by any process of the same user, which means the memory exposure documented here is the second-easiest way to get them.
Logging sensitive data
import logging
# VULNERABLE - Password logged to file/memory
def login(username, password):
logging.debug(f"Attempting login for {username} with password {password}")
# Password now in log strings, log files, and memory
result = authenticate(username, password)
logging.info(f"Login successful for {username}")
return result
Why this is vulnerable: The f-string is evaluated before logging.debug() is called, so the message is built even when the level is disabled and the log record is discarded - the check happens too late to prevent the string existing. Deferring with logging.debug("login for %s with %s", user, password) avoids the formatting but still hands the logger the password as an argument it retains for the life of the record.
Tracebacks are the leak that survives fixing the log line. A logging.exception() call, or any handler configured with a formatter that renders local variables, captures the frame's locals - which includes the parameter - so removing the explicit password from the message does not remove it from the file.
Concatenating sensitive strings
# VULNERABLE - Creates multiple immutable copies in memory
def build_connection_string(password):
# Each concatenation creates new immutable string
conn_str = "Server=db.example.com;"
conn_str += "User=admin;"
conn_str += f"Password={password};" # Password copied to new string
conn_str += "Database=prod"
# Multiple copies of password in memory
return conn_str
Why this is vulnerable: Each += builds a new string and abandons the old one. CPython has an in-place optimisation for this shape, but it applies only when the left operand's refcount is exactly one, so any other live reference - a debugger, a closure, a second name - silently turns it back into copy-and-discard, and the intermediate values are freed without being cleared.
The password is also the wrong thing to put in a connection string at all. Where the driver accepts a separate password= argument, the value never becomes part of a longer string that gets logged, repr'd in a traceback, or passed to a connection pool that keeps its DSN for reconnection.
Not clearing sensitive data
from cryptography.fernet import Fernet
# VULNERABLE - Encryption key persists as string
def encrypt_data(data):
# Key loaded as immutable string
key = Fernet.generate_key()
cipher = Fernet(key)
encrypted = cipher.encrypt(data.encode())
# Key never cleared from memory
return encrypted, key
Why this is vulnerable: Fernet.generate_key() returns bytes, which is immutable in Python just as str is - so the key cannot be cleared, and returning it from the function extends its life past whatever scope the caller gives it.
The lifetime is the thing to fix first. A key held for the duration of one encrypt call is a much smaller target than one returned to a caller that stores it, and a key that never enters the process - because a KMS or an HSM performs the operation - cannot be read out of a dump at all. Where the key must be local, bytearray is the mutable equivalent and can be overwritten before it goes out of scope.
Secure Patterns
Using bytearray for mutable secrets
import getpass
import ctypes
def secure_authenticate():
"""Use bytearray for mutable password that can be cleared"""
# Get password as string initially
password_str = getpass.getpass("Enter password: ")
# Convert to bytearray (mutable)
password = bytearray(password_str.encode('utf-8'))
try:
# Use password for authentication
result = verify_password(password)
return result
finally:
# Explicitly clear password from memory
for i in range(len(password)):
password[i] = 0
# Delete reference
del password
Why this works:
bytearrayis mutable: the password can be overwritten with zeros in place, which astrnever can befinallyblock ensures cleanup: the clear runs whetherverify_password()returns normally or raises- Minimizes cleartext persistence: the conversion happens on the line after
getpass.getpass()returns, so the span in which the password exists only as an unclearablestris as short as this code can make it - Non-deterministic GC requires explicit clearing: freeing the object returns its memory to the allocator without zeroing it, at a moment the code does not choose
- Overwriting each byte clears this buffer: Loop-based clearing (
password[i] = 0) removes the contents of thebytearray, but earlier immutable strings or temporarybytescopies may still exist
Secure key handling with explicit clearing
import base64
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
class SecureKeyManager:
"""Manage cryptographic keys with secure memory handling"""
def __init__(self):
self._key = None
def load_key(self):
"""Load key material as a bytearray so it can be cleared.
The environment variable carries base64-encoded *key material*, not a
passphrase. AES accepts only 16, 24 or 32 raw bytes, so passing the
UTF-8 bytes of an arbitrary string raises `ValueError: Invalid key size`
for every value that is not coincidentally the right length.
"""
encoded = os.environ.get('ENCRYPTION_KEY')
if not encoded:
raise ValueError("ENCRYPTION_KEY is not set")
try:
# binascii.Error subclasses ValueError, so this catches both a bad
# alphabet and bad padding
self._key = bytearray(base64.b64decode(encoded, validate=True))
except ValueError as exc:
raise ValueError("ENCRYPTION_KEY must be base64-encoded") from exc
if len(self._key) not in (16, 24, 32):
self.clear_key()
raise ValueError("ENCRYPTION_KEY must decode to 16, 24 or 32 bytes")
def encrypt(self, plaintext):
"""Encrypt data using the key.
Returns nonce + tag + ciphertext. All three are needed to decrypt:
the nonce is not secret but must be stored, and without the GCM tag
there is nothing to authenticate against on the way back.
"""
if not self._key:
raise ValueError("Key not loaded")
nonce = os.urandom(12) # 96-bit nonce, the size NIST SP 800-38D recommends for GCM
cipher = Cipher(
algorithms.AES(bytes(self._key)),
modes.GCM(nonce),
backend=default_backend()
)
encryptor = cipher.encryptor()
ciphertext = encryptor.update(plaintext) + encryptor.finalize()
# encryptor.tag is only populated after finalize()
return nonce + encryptor.tag + ciphertext
def decrypt(self, blob):
"""Reverse of encrypt(). Present so the format above is testable:
an AES-GCM example that only encrypts will look correct while
producing output that can never be read back."""
if not self._key:
raise ValueError("Key not loaded")
nonce, tag, ciphertext = blob[:12], blob[12:28], blob[28:]
cipher = Cipher(
algorithms.AES(bytes(self._key)),
modes.GCM(nonce, tag),
backend=default_backend()
)
decryptor = cipher.decryptor()
return decryptor.update(ciphertext) + decryptor.finalize()
def clear_key(self):
"""Explicitly clear key from memory"""
if self._key is not None:
# Overwrite with zeros
for i in range(len(self._key)):
self._key[i] = 0
# Drop the reference to the now-zeroed buffer
self._key = None
def __del__(self):
"""Ensure key is cleared on object destruction"""
self.clear_key()
# Usage
manager = SecureKeyManager()
try:
manager.load_key()
encrypted = manager.encrypt(b"sensitive data")
assert manager.decrypt(encrypted) == b"sensitive data"
finally:
manager.clear_key()
Why this works:
- Mutable storage: the key lives in a
bytearray, soclear_key()can overwrite it with zeros rather than leaving it resident until the process ends - Deterministic cleanup:
__del__is a safety net with no guaranteed timing, so it is theclear_key()call in afinallyblock that decides when the key goes - The environment variable carries key material, not a passphrase:
algorithms.AEStakes 16, 24 or 32 raw bytes and raisesValueError: Invalid key sizefor anything else, so handing itos.environ['ENCRYPTION_KEY'].encode()fails for every value that is not coincidentally that length. Base64-decoding and checking the length up front turns a runtime failure at the first encrypt into a startup failure with a message that says what is wrong. Where the input genuinely is a passphrase, derive from it with a KDF rather than using it as a key - AES-GCM provides authenticated encryption: confidentiality and integrity together, so a modified ciphertext fails to decrypt rather than decrypting to something else
- Nonce and tag are returned, not discarded: GCM only delivers integrity if the authentication tag travels with the ciphertext, and
encryptor.tagis populated only afterfinalize(). Dropping either the nonce or the tag produces output that cannot be decrypted at all - a mistake that looks correct because the encrypt call itself still succeeds - Fail-fast behavior: the
if not self._keycheck rejects encrypt and decrypt calls made after the key has been cleared - Long-running applications: in a web server or daemon the key otherwise stays resident between requests, so clearing it at session end or shutdown matters more than it does in a short-lived script, and neither moment is one the garbage collector knows about
Context manager for automatic cleanup
import getpass
from contextlib import contextmanager
@contextmanager
def secure_password():
"""Context manager that automatically clears password"""
password_str = getpass.getpass("Enter password: ")
password = bytearray(password_str.encode('utf-8'))
try:
yield password
finally:
# Always clear password, even on exception
for i in range(len(password)):
password[i] = 0
del password
# Usage
def login():
with secure_password() as password:
# Use password
result = authenticate(bytes(password))
return result
# Password automatically cleared after context exits
Why this works:
- Automatic cleanup: the
@contextmanagerandwithprotocol runs thefinallyblock even when the body raises - Yield mechanism:
yieldhands the caller thebytearrayitself, so the buffer being used is the onefinallyoverwrites and unbinds on exit - Reduces errors: the
try/finallyis written once here instead of in every function that handles a password - Temporary bytes conversion:
bytes(password)creates an immutable copy that cannot be cleared, and is what the crypto library requires - Idiomatic Python:
with secure_password() as password:puts the password's lifetime in the syntax, where a reader can see where it ends
Memory locking with mlock (Linux)
import ctypes
import ctypes.util
import os
class SecureMemory:
"""Secure memory allocation with mlock to prevent swapping"""
def __init__(self, size):
self.size = size
self.buffer = bytearray(size)
# use_errno is required to find out why a call failed; without it the
# only information available is mlock's -1.
libc = ctypes.CDLL(ctypes.util.find_library('c'), use_errno=True)
# Declaring the signature is not optional on 64-bit. ctypes defaults an
# undeclared integer argument to C int, so the address is truncated to
# 32 bits and mlock receives a pointer that was never allocated.
libc.mlock.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
libc.mlock.restype = ctypes.c_int
libc.munlock.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
libc.munlock.restype = ctypes.c_int
# Lock memory to prevent swapping to disk
addr = ctypes.addressof(ctypes.c_char.from_buffer(self.buffer))
if libc.mlock(addr, size) != 0:
err = ctypes.get_errno()
raise OSError(err, f"mlock failed: {os.strerror(err)}")
self.locked = True
def write(self, data):
"""Write data to secure buffer"""
if len(data) > self.size:
raise ValueError("Data too large for buffer")
# Clear buffer first
for i in range(self.size):
self.buffer[i] = 0
# Write new data
for i, byte in enumerate(data):
self.buffer[i] = byte
def read(self):
"""Read data from secure buffer"""
return bytes(self.buffer)
def clear(self):
"""Explicitly clear buffer"""
for i in range(self.size):
self.buffer[i] = 0
def __del__(self):
"""Unlock and clear memory on destruction"""
if hasattr(self, 'locked') and self.locked:
self.clear()
# Unlock memory
libc = ctypes.CDLL(ctypes.util.find_library('c'))
addr = ctypes.addressof(ctypes.c_char.from_buffer(self.buffer))
libc.munlock(addr, self.size)
# Usage
secure_mem = SecureMemory(256)
try:
secure_mem.write(b"sensitive password")
# Use the secure memory
password = secure_mem.read()
finally:
secure_mem.clear()
Why this works:
- Prevents swapping: the
mlock()system call, reached throughctypesand libc, pins the buffer's pages in RAM so the kernel cannot write them out - Persistent file protection: swap persists on disk, so a page written there can be recovered after the process has exited
- Implementation: the buffer is a
bytearray, andctypes.addressof()on ac_charview of it givesmlock()the address whose pages the kernel marks non-swappable - Cleanup:
clear()overwrites bytes with zeros and__del__callsmunlock(), but only the explicitfinallygives you either at a predictable time read()hands back an uncleanable copy, and it is the whole buffer:bytes(self.buffer)is immutable, so the value it returns cannot be zeroed and is not covered byclear()- the same limitation thememfdsection below describes foros.read(). It also returns allself.sizebytes including the zero padding, so a caller expecting only whatwrite()put there needs to track the length itself. Where the copy matters, operate onself.bufferin place rather than reading it out- Platform/permissions: Linux/Unix-specific (Windows has
VirtualLock()); requiresCAP_IPC_LOCKcapability or sufficientRLIMIT_MEMLOCK; use sparingly (locking too much impacts performance)
Using memfd for RAM-backed temporary storage
import os
import tempfile
def process_sensitive_data(sensitive_data):
"""Use an anonymous in-memory file instead of a named temporary file"""
# Linux only. This removes the named file, not the swap exposure - see the
# note below the example.
fd = os.memfd_create("sensitive", os.MFD_CLOEXEC)
try:
# Write sensitive data
os.write(fd, sensitive_data)
# Seek to beginning
os.lseek(fd, 0, os.SEEK_SET)
# Read and process
data = os.read(fd, len(sensitive_data))
result = process(data)
return result
finally:
# Closing drops the last reference and releases the pages. They are not
# zeroed on release, and `data` above is an immutable bytes object that
# cannot be cleared at all.
os.close(fd)
Why this works:
- RAM-backed file descriptor:
memfd_create()returns a descriptor for an anonymous file that lives in memory and has no entry in any filesystem - Automatic cleanup:
MFD_CLOEXECcloses the descriptor acrossexec(), so a child process does not inherit it, and the memory is released when the last descriptor closes - with nothing on disk to clean up - No ordinary temp-file persistence: Unlike
tempfile//tmp, memfd has no pathname to clean up, but it does not replacemlock(), dump exclusion, or process hardening for secrets - File-like interface:
os.fdopen(fd, 'rb')wraps it for anything that expects a file object - Container-friendly: removes the named temporary file, which is the attack surface this CWE's sibling CWE-377 is about - no path for another process to pre-create, race or read afterwards
- It is not swap protection, and that is the limit worth stating: a memfd is backed by tmpfs, so its pages are ordinary anonymous memory and the kernel may write them to swap or include them in a core dump exactly as it would a
bytearray. Excluding that needsmlock()on a mapping of the descriptor,madvise(MADV_DONTDUMP), or disabling swap -memfd_create()supplies none of them os.read()reintroduces an uncleanable copy: it returns an immutablebytes, sodatacannot be zeroed andos.close(fd)does nothing about it. Read into a preallocated buffer withos.readv()orreadinto()where the copy matters
Secure password comparison
import hmac
import hashlib
# OWASP Password Storage Cheat Sheet, for PBKDF2-HMAC-SHA256
PBKDF2_ITERATIONS = 600_000
def secure_password_verify(stored_salt, stored_hash, password_input):
"""Verify password without keeping plaintext in memory"""
# Convert to bytearray immediately
password = bytearray(password_input.encode('utf-8'))
try:
# The salt is per-user and stored with the hash. A fixed salt means
# identical passwords share a hash, so one precomputed table covers
# every account that chose the same password.
input_hash = hashlib.pbkdf2_hmac(
'sha256',
bytes(password),
stored_salt,
PBKDF2_ITERATIONS
)
# Constant-time comparison
result = hmac.compare_digest(input_hash, stored_hash)
return result
finally:
# Clear password from memory
for i in range(len(password)):
password[i] = 0
del password
Why this works:
- Dual security mechanisms:
hmac.compare_digest()covers the timing side and thebytearrayoverwrite covers the memory side - Minimal cleartext exposure: Convert password to
bytearrayimmediately, clear infinallyblock; temporarybytes(password)for PBKDF2 input, then overwrite original - Cryptographic strength: PBKDF2-HMAC-SHA256 with 600,000 iterations follows current OWASP guidance where PBKDF2 is required; tune parameters on production hardware and prefer Argon2id or bcrypt where appropriate
- Constant-time comparison:
hmac.compare_digest()takes the same time wherever the first differing byte falls, while==returns at that byte and leaks its position - Custom auth use case: this is the pattern for an application that does not use Django's or Flask's own password handling
Django with secure session handling
There is no
bytearrayin this example, and that is the point.authenticate()takes astr, Django's request parser already built one from the POST body, and no hasher indjango.contrib.auth.hashersaccepts anything else. Copying thatstrinto abytearrayonly to clear thebytearraywould add a plaintext copy, wipe the copy it added, and leave the original exactly where it was - a ritual that reads as defence in depth and is a net increase in cleartext. On this path the controls that do something are the ones below the code.
from django.contrib.auth import authenticate, login
from django.http import HttpResponse
def login_view(request):
"""Login view - see the note above on why nothing here is cleared"""
if request.method == 'POST':
username = request.POST.get('username')
password = request.POST.get('password')
user = authenticate(request, username=username, password=password)
if user is not None:
login(request, user)
return HttpResponse("Login successful")
return HttpResponse("Invalid credentials", status=401)
Why this works:
- Framework security: Django's password hashers use salted adaptive password hashing and timing-resistant verification.
ModelBackend.authenticate()routes both branches throughcheck_password_with_timing_attack_mitigation()(Django 6.1; the same behaviour has been inline in the backend since ticket #20760), which runsset_password()against a throwaway user when the username does not exist - so Django closes the timing oracle that the Flask and FastAPI endpoints above have to close by hand - Nothing is cleared because nothing can be: the request body was parsed into a
strbefore this view ran, and every downstream API takes astr. Recording the finding as "unavoidable at this layer" is a legitimate outcome and more useful than shipping a clear that runs on a copy nobody used - What to do instead, in order: shorten worker lifetime so a leaked password does not sit in a long-lived process; disable core dumps for the application user; keep debuggers and
py-spy-style attach off production hosts; make sure the exception path does not render locals (seeLogging sensitive dataabove) - Session-based auth:
login()creates a session with a cookie-stored ID, so the password is not retransmitted on subsequent requests - which is the largest single reduction in how often the plaintext exists at all - Secure cookies:
SESSION_COOKIE_SECURE,SESSION_COOKIE_HTTPONLY,SESSION_COOKIE_SAMESITE = 'Strict'prevent interception and XSS access, and blunt CSRF -SameSiteis defense in depth there, not a substitute for a CSRF token
Flask with secure credential handling
import os
from flask import Flask, request
import bcrypt
app = Flask(__name__)
# A real bcrypt hash of a value nobody knows, at the same cost as a live one.
# Verified against when the username does not exist, so both branches take the
# same time. bcrypt.checkpw(pw, b'') raises ValueError in microseconds, so a
# placeholder will not do.
DUMMY_HASH = bcrypt.hashpw(os.urandom(32), bcrypt.gensalt())
@app.route('/login', methods=['POST'])
def login():
"""Secure login endpoint"""
username = request.form.get('username')
password_str = request.form.get('password')
# Convert to bytearray for clearing
password = bytearray(password_str.encode('utf-8'))
try:
# Get stored hash from database; None when the user does not exist
stored_hash = get_user_hash(username)
# Hash unconditionally. Returning early on a missing user would hand
# bcrypt.checkpw() a None (TypeError, so a 500 rather than a 401) and
# would answer in microseconds where a real verification takes ~200 ms.
matched = bcrypt.checkpw(bytes(password), stored_hash or DUMMY_HASH)
if stored_hash is not None and matched:
# Generate session token
token = create_session(username)
return {'token': token}, 200
return {'error': 'Invalid credentials'}, 401
finally:
# Always clear password
for i in range(len(password)):
password[i] = 0
del password
Why this works:
- Immediate conversion:
request.formhands the view astr, and copying it into abytearrayon the next line gives this code something it can zero - Bcrypt security:
checkpw()verifies against a salted adaptive bcrypt hash, but temporary Pythonbytesobjects created for the library may remain until garbage collection - Short-lived copy:
bytes(password)creates temporary immutable view for bcrypt (which requiresbytes), but originalbytearrayis cleared infinally - The unknown user costs the same as the known one: measured with bcrypt at the default cost, an early return answers in 0.001 ms against 210 ms for a real verification - a gap any client can time regardless of how generic the message is. Verifying against
DUMMY_HASHand discarding the result closes it, and also removes theTypeErroraNonehash would have raised. The endpoint now runs bcrypt on every request, so rate-limit it - Explicit cleanup: the
finallyblock runs even whenget_user_hash()raises or token creation fails
Framework-Specific Guidance
Django password handling
from django.contrib.auth.hashers import make_password, check_password
def register_user(username, password_str):
"""Securely hash password during registration"""
# Django hashes the password, but this input string cannot be explicitly cleared by this code.
password_hash = make_password(password_str)
# Store hash, not plaintext
User.objects.create(username=username, password=password_hash)
FastAPI with secure authentication
import os
from fastapi import FastAPI, Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
import bcrypt
app = FastAPI()
# Same reasoning as the Flask endpoint above: a genuine hash at live cost.
DUMMY_HASH = bcrypt.hashpw(os.urandom(32), bcrypt.gensalt())
@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
"""Secure token endpoint"""
password = bytearray(form_data.password.encode())
try:
user = get_user(form_data.username)
# `not user or not bcrypt.checkpw(...)` short-circuits, so an unknown
# username skips the hash entirely and answers ~200 ms sooner.
matched = bcrypt.checkpw(
bytes(password), user.hashed_password if user else DUMMY_HASH)
if not user or not matched:
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token(data={"sub": user.username})
return {"access_token": token, "token_type": "bearer"}
finally:
for i in range(len(password)):
password[i] = 0
del password
Considerations
This is a mitigation, not an elimination, and the difference matters when deciding how far to go. A managed runtime gives you no way to guarantee a secret is gone: the garbage collector copies values as it compacts, immutable strings cannot be overwritten at all, pages may be written to swap, and a crash dump captures whatever happens to be resident. Clearing buffers shortens the window an attacker with memory access must hit. It does not close it. Say which you are buying before spending much effort.
The boundary is the API you have to call. Holding a credential in a mutable buffer only helps if everything downstream accepts one. The moment a library requires a string, the conversion creates a copy you cannot clear, and the care taken upstream buys almost nothing. Judge by whether the whole path can avoid the conversion; if it cannot, spend the effort on the operational controls instead.
bytearray helps only as far as the next API. str and bytes are
immutable and short strings may be interned for the life of the process, so a
secret that becomes one cannot be cleared. A bytearray can be zeroed, but only
if every consumer accepts it - many libraries take str, and the conversion
creates the copy you were avoiding. The ctypes.memset trick that circulates
for clearing str objects relies on CPython implementation details and is not
something to depend on.
Most of the real exposure is operational rather than in the code. Whether process dumps are enabled, whether swap is encrypted, whether the host is shared, how long worker processes live, and whether debuggers can attach in production will usually change the risk more than any in-process buffer handling. If you can only do one thing, restricting dump generation and shortening process lifetime tends to beat clearing arrays.
The strongest version of this fix is not holding the secret at all. Fetching a credential from a vault at the point of use, keeping it for the shortest span the operation needs, and letting the platform hold anything long-lived removes the question rather than managing it.
Testing
- Normal input: verify login, password hashing, encryption, and token flows still work with scoped
bytearrayhandling. - Boundary input: test authentication failures, exceptions, cancelled requests, and missing form fields to confirm cleanup paths run.
- Malicious input: capture a controlled memory dump or traceback in a non-production environment and search for known test secrets.
Additional Resources
- Cryptography library
- CWE-316: Cleartext Storage of Sensitive Information in Memory
- OWASP Password Storage Cheat Sheet - the source for the PBKDF2 iteration count, bcrypt work factor, and Argon2 parameters used here
- OWASP Secure Coding Practices
- Python secrets module