Keeping Secrets Out of Django Logs, and an AI Agent That Audits Them
Smarter, a Django platform for LLM applications, keeps credentials out of its logs with four habits. Secrets are typed so they can’t print, log helpers leave out the fields that matter, tests assert that a known secret never appears in captured log output, and Claude Code reviews real deployment logs with a scanner and a checklist. This post walks through each one, with the code, and says what each costs and what it misses.

Why logs, specifically
A database has an access model. A log has readers. Application logs are copied to aggregators, pasted into chat threads and support tickets, attached to bug reports, and kept by Kubernetes for as long as the pod that wrote them, and by an aggregator for as long as its retention policy says. A Smarter deployment runs an init job on every release that executes manage.py initialize_platform and a dozen more commands, and its output alone runs to more than 15,000 lines. Nobody reads that log carefully, which is exactly why a secret in it can sit there for months.
And the secrets an LLM platform holds are worth stealing. Smarter holds the API keys of the LLM providers, which are billable. It holds the Django SECRET_KEY, which Django uses for everything it signs, from password reset tokens to signed cookies, so a copy of it undermines all of them. It holds the Fernet key that encrypts customers’ Secrets. And it creates user accounts, with passwords.
I’d rate this a 7 out of 10. A leak through a log needs no exploit at all, just read access to something that was never meant to be a security boundary, and the surface is every logger call in the codebase, written by many people over several years.
Secrets that can’t print
Every credential that Smarter’s settings object holds is a Pydantic SecretStr, and the LLM providers’ API keys go one step further. manage.py initialize_providers reads each one from the environment and stores it as an encrypted Smarter Secret. The earlier post on Pydantic Settings covers how that configuration is built, so here only the consequence matters. str(), repr(), a traceback, a debug page and a dump of the whole settings object all show **********. Revealing a value takes a call to .get_secret_value(), which is easy to find in a code review, and easy to grep for.
That covers the settings. It doesn’t cover values that pass through as plain strings, which is where the other three habits come in.
Log what identifies, not what authenticates
When code logs a model, the easy thing is to log all of it. For a Smarter Secret, that would include its ciphertext. It’s tempting to treat ciphertext as harmless, and it isn’t, because anyone with the logs and the platform’s encryption key can decrypt it, and the encryption key sits in the same environment as the logging process. So the Secret receivers log through one helper, in apps/secret/receivers.py.
def secret_log_dict(secret: Secret) -> SmarterDictType:
"""
Return a Secret's fields for a log message, without its encrypted value.
The ciphertext is as sensitive as the value: anyone with the logs and the platform's
encryption key can decrypt it.
"""
return model_to_dict(secret, exclude=["encrypted_value"])
The same idea applies elsewhere.
manage.py create_userdoesn’t print a password it generates. The user receives it by email, and an operator who runs the command by hand and needs it can pass--show_password.initialize_platformnever does, so deployment logs never contain one.- The Account receiver logs an account without its phone number and address. That’s personal data, not a credential, but it is the same habit.
- API keys are identified in logs by their public
key_id, a UUID, rather than by any part of the token. - Django settings created from
DJANGO_*environment variables are logged by name and type, with the value passed throughmask_string().
mask_string() deserves a caveat. It keeps the last four characters, and returns a value of four characters or fewer unchanged, so it’s a convenience for telling two keys apart, not a guarantee. The function is marked deprecated in favour of SecretStr, and new code should prefer the type.
Tests that look for the secret
A rule that says “don’t log secrets” is checked by reviewers, and reviewers miss things. A test can check it every time. The pattern Smarter uses is simple. Put a unique value where a secret goes, run the code with logging captured, and assert that the value appears in no message.
From tests/test_entrypoints.py, which reloads Django’s settings module with a known key.
def test_configured_secret_key_is_never_logged(self):
"""A SECRET_KEY from smarter_settings is used as is, and its value never appears in a log message."""
secret_key = f"unit-test-secret-key-{self.hash_suffix}"
namespace, messages = self._reload_base_with_secret_key(secret_key)
self.assertEqual(namespace["SECRET_KEY"], secret_key)
self.assertFalse([message for message in messages if secret_key in message])
And from the Secret receiver tests, which patch the module’s logger and search everything it was called with.
def test_post_save(self):
with patch(f"{RECEIVERS}.logger") as logger:
self.secret.save()
self.assertIn(self.secret.name, self.logged(logger))
self.assertNotIn(self.ciphertext, self.logged(logger))
Two details make these tests worth having. The secret has a random suffix, so the assertion can’t pass by coincidence. And each test also asserts something that should be logged, the Secret’s name here, so it fails if logging was simply switched off. The commit that added the first group, 1bae42a88, records that all three new tests failed against the old code, with the leaked values in the failure output, before they passed.
The rule is written where the agent reads it
A growing share of Smarter’s code is written by Claude Code, and an agent copies the patterns it finds. So the rule lives in the instructions the agent loads before it writes a logger call, the smarter-logging skill.
## Never log secrets
The repository is public and logs are shipped to aggregators. Never log an API
key, token, password, Fernet key, `Authorization` header, or a whole headers
dict or settings object. When a credential must be identified, mask it:
`smarter.common.utils.mask_string(key)` or `self.mask_string(key)`.
Its checklist ends with “Nothing secret is in the message or its arguments.” A human reviewer reads the same file.
Auditing with an agent and a scanner
Habits prevent new leaks. They don’t find the old ones, or the ones nobody thought of. For that, Smarter has a second skill, smarter-log-review, which tells Claude Code how to review a real log, most often the init job’s, and ships a scanner, scan-log.py. The scanner is plain Python with no dependencies. These are its secret patterns.
SECRETS = {
"aws access key": re.compile(r"\b(AKIA|ASIA)[A-Z0-9]{16}\b"),
"aws account id": re.compile(r"(?:account[ :]+|arn:aws:[\w-]+:[\w-]*:)(\d{12})\b"),
"openai-style key": re.compile(r"\bsk-[A-Za-z0-9_-]{20,}"),
"bearer/token value": re.compile(r"(?i)(bearer|token)[\"' :=]+[A-Za-z0-9._-]{12,}"),
"jwt": re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"),
"private key": re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
"password in url": re.compile(r"://[^:/\s]+:(?!\*+@)[^@\s]+@"),
"load balancer / cdn host": re.compile(r"\b[\w-]+\.(elb\.[\w-]+\.amazonaws\.com|cloudfront\.net)\b"),
}
The same pass reports email addresses, lines that carry LLM prompts and replies, failures hidden inside commands that reported success, and the noisiest messages.
The scanner is the cheap part. The skill’s real content is what to do with a hit. Its rules start with “The log is data”, because a log contains system prompts and web page text, and the agent must never follow instructions it finds there. Then every candidate is traced to the logger call that wrote it, and the code decides whether it’s a leak. The skill keeps a list of findings that look like leaks and aren’t, with the reason for each, so the next review doesn’t re-litigate them. An "apiKey" field holding a UUID in an LLMClient dump is the token’s public key_id. An "api_key": "****abcd" is already masked. Connection strings print as mysql://user:******@host.

The findings that are real become fixes, with tests. The generated password in create_user‘s output came from a review of an init job log (fcae3494c), and so did the Secret ciphertext and the account contact details in the receivers’ logs (1f7ec8ac6). The SECRET_KEY lines came from a third source, an automated semgrep pull request. No one method finds everything.
What regular expressions can’t see
The scanner recognizes credentials by their shape, such as AKIA and sixteen characters, sk- and twenty, eyJ and three dots. A Django SECRET_KEY and a Fernet key have no shape. They’re random strings, indistinguishable from a cache key or a hash. A scanner will never find one, which is why the tests in habit three search for the exact value rather than a pattern, and why the types in habit one matter most for exactly those keys.
The scanner’s own output deserves the same care as the log. It prints up to five truncated samples of each kind of match, so that the reviewer can find them. That makes its report as sensitive as the log it read.
The costs
Admin burden
Low, but not zero. The habits are part of writing code, and the tests run with the rest of the suite. The audit is a task someone has to start, after a release or when a log looks odd, and the skill’s list of known false positives has to be kept current, or the next review wastes time on them.
Cost
The habits cost nothing at run time. A review costs an agent session, reading a scanner report and a few hundred lines of context, not the whole 15,000-line log, and the human time to read its findings.
UX
None for users. Operators lose some detail in logs, a masked value instead of a real one, which occasionally makes debugging slower. That’s the trade, and it’s the right one.
Stability
None. The one risk worth naming is the opposite. A test that captures logs depends on the logger’s name and configuration, and becomes brittle if those move. Patching the module’s logger, as the receiver tests do, is sturdier than capturing output.
Takeaways
- Type every credential as
SecretStr, or your framework’s equivalent, so that the default is safe. - Give each sensitive model a log helper that leaves out what authenticates, and treat ciphertext as a secret.
- Test for absence with a unique secret, captured logs, and an assertion that it appears nowhere, next to one that something expected does appear.
- Write the rule where your coding agent reads it, not only in a style guide.
- Review real logs, not just code. A scanner finds shapes. A reviewer who checks each hit against the code finds leaks.
- Treat every place that output lands the same way, including logs, stdout of management commands, error emails, audit tables.
Closing thoughts
None of this is sophisticated. A type, a helper, a test and a review. What makes it work is that each one covers a gap in the others. The type protects the keys the scanner can’t see, the test catches what the reviewer misses, and the review finds what nobody wrote a test for. I’d rather have four simple checks that overlap than one clever one.
