Fernet in Django: How Smarter Stores the Credentials Its LLM Apps Depend On

Smarter keeps every credential that its resources need, from provider API keys and database passwords to MCP server tokens and search API keys, in one Django model, Secret, whose value is stored as Fernet ciphertext from the Python cryptography package. This post describes what that model encrypts, who can read a value back, how reads are tracked, and how the ciphertext is kept out of logs. It also covers the burden that a single symmetric key puts on whoever runs the platform.

An LLM platform collects credentials as a matter of course. An LLMClient calls a provider with an API key. A SQL plugin connects to a customer’s database with a password. An MCP client sends a bearer token. A vector store needs Pinecone’s key, and a web search plugin needs a search API’s. Each of those is a credential that belongs to someone else and was lent to the platform. The platform has to store them, hand them to the right code at the right moment, and otherwise never show them to anyone.

One model for every credential

In Smarter, those resources don’t keep credentials in columns of their own. Each has a foreign key to a Secret, and a manifest names the Secret rather than containing the value. A Secret manifest is itself an ordinary SAM resource, from the docs.

apiVersion: smarter.sh/v1
kind: Secret
metadata:
  name: example_secret
  description: an example secret manifest for the Smarter API Secret
spec:
  config:
    expiration_date: 2026-06-05
    value: <** your unencrypted credential value **>

A Proxy, an LLM host or an SQL connection then refers to it by name.

spec:
  apiKey: anthropic_api_key

That one level of indirection does most of the work. The manifests that describe an application can be committed, reviewed and shared, because they hold names, not values. A credential is changed in one place, and every resource that names it picks up the new value on its next use. And because there is only one model, there is one place where encryption, access rules and logging have to be right. The platform’s own provider keys go through the same path. At initialization, provider/utils.py writes them into Secrets that the Smarter admin owns, and the built-in Proxies and Providers read them from there.

Diagram of how the LLMClient or Proxy's provider API key, an SQL plugin's database password, an MCP client's bearer token, a vector store's Pinecone key and a web search plugin's search API key all point by name to one Secret model, which stores only Fernet ciphertext in encrypted_value. A single FERNET_ENCRYPTION_KEY from the environment, validated as 32 bytes at startup, encrypts and decrypts it. Below, it notes that a database copy alone reveals nothing, the database and the key together reveal everything, and a running pod has the key in its environment.

What Fernet gives you, and what it doesn’t

Fernet is authenticated symmetric encryption, built from AES-128 in CBC mode with a random IV, plus an HMAC-SHA256 over the result and a timestamp. Two properties make it a good default for a Django model field. It is hard to misuse, since there are no modes, nonces or padding choices to get wrong. And it refuses to decrypt anything that has been altered. Someone who can write to the database can delete a Secret, but without the key can’t edit its ciphertext into a different value that the platform will accept.

The model stores nothing else. From secret/models.py.

encrypted_value = models.BinaryField(help_text="Read-only encrypted representation of the secret's value.")

@classmethod
def encrypt(cls, value: str) -> bytes:
    if not value or not isinstance(value, str):
        raise SmarterValueError("Value must be a non-empty string")
    fernet = cls.get_fernet()
    retval = fernet.encrypt(value.encode())
    return retval

There is no plaintext column, and no transient field that a careless save() could persist. Code that wants the value calls get_secret(), which decrypts it on demand.

What Fernet doesn’t give you is a key hierarchy. Smarter uses one key for the whole platform, FERNET_ENCRYPTION_KEY, supplied as an environment variable and held by Pydantic as a SecretStr. At startup, a validator checks that it decodes to exactly 32 bytes, so a truncated key fails the deploy rather than the first request that needs a Secret. The protection is therefore exactly as strong as the separation between the database and that key. A copy of the database, such as a backup, a replica or a developer’s snapshot, reveals nothing on its own. A copy of the database together with the key reveals everything. That’s the right model for most self-hosted platforms, but it is not envelope encryption with a KMS, and I’d rather say so than let the word “encrypted” imply more.

Who can read a value back

Every Secret belongs to a user profile, and through it to an account, like every other Smarter resource, and the ownership-aware querysets that govern every other resource decide who may read, change or delete it. The paths by which a value can come back out are deliberately few.

  • Listing (get) uses a serializer whose fields are the name, description, owner, timestamps, expiry and readiness. The value isn’t among them, and a comment in the broker says it never may be.
  • Describing a single Secret returns its decrypted value, because that is how its owner checks or exports it. It is the one API response that carries a value, so anything downstream that records responses should treat it as sensitive.
  • The Django admin shows a decrypted value only to a user with ownership permission on that Secret. Everyone else sees a masked string that keeps only its last four characters.
  • Resources that use a Secret, such as a Proxy, decrypt it in memory at the moment they need it, and the value goes out only in the request that needs it.

A resource also may not borrow a Secret across accounts. A Proxy, for example, may only send a Secret that belongs to its own owner’s account, which stops someone from writing a Proxy that ships the platform’s key to a host they control.

Secrets can also expire. expires_at is optional, and is_expired() is checked by the code that uses the Secret. A Proxy whose key Secret has expired answers 503 with a message that names the Secret, rather than sending a stale credential to the provider and passing the provider’s 401 back.

Diagram of the four ways a Secret's value can come back out. Listing returns no value, only name, description, owner, timestamps, expiry and readiness. Describing one Secret returns the decrypted value to its owner. The Django admin decrypts only for a user with ownership permission and otherwise masks all but the last four characters. A resource that uses the Secret decrypts it in memory for the request that needs it. Two more rules apply. A resource may only use a Secret from its owner's account, and an expired Secret makes a Proxy answer 503.

Tracking access without turning reads into writes

Every call to get_secret() sends a secret_accessed signal, and by default it also stamps last_accessed. The stamp is written with a queryset update rather than save().

if update_last_accessed:
    # a queryset update, rather than save(): reading a secret is not an edit, so it
    # neither sends secret_edited nor invalidates and reloads the secret's cached objects.
    self.last_accessed = timezone.now()
    Secret.objects.filter(pk=self.pk).update(last_accessed=self.last_accessed)
secret_accessed.send(sender=self.__class__, secret=self, user_profile=self.user_profile)

This detail is easy to get wrong. If a read went through save(), it would count as an edit. Every read would fire secret_edited, invalidate the Secret’s cache entries and every list page that shows it, and make the audit trail say that the Secret was changed when it was only used. A queryset update writes one column and nothing else.

Hot paths can opt out of the write. The Proxy reads its provider key on every request with update_last_accessed=False, so a busy Proxy doesn’t turn into a stream of UPDATEs on one row. The signal still fires. last_accessed therefore answers “when did a person or a configuration step last look at this?”, and the secret_accessed signal is the hook for anyone who wants every use, for example to feed a SIEM.

Side-by-side comparison. If a read went through save(), it would fire secret_edited, invalidate the Secret's cache and every list page that shows it, and make the audit trail say the Secret changed. get_secret() instead does a queryset update of last_accessed, which writes one column, and the secret_accessed signal still fires. On the Proxy's hot path, update_last_accessed=False skips the UPDATE but the signal still fires.

Keeping the ciphertext out of logs

It’s tempting to think that ciphertext is safe to log. It isn’t, because anyone who has the logs and the platform’s key can decrypt it, and logs are copied to far more places than databases are. Smarter’s Secret receivers build their log output through one helper, from 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 access receiver logs the Secret’s name, id and owner, never its value. Tests save a Secret, capture what the receivers log, and assert that neither the ciphertext nor the field name appears in it. The change that introduced the helper, 1f7ec8ac6, came out of a review of a production init job’s log, and a test is what keeps it from coming back.

How important is it?

Credential storage covers a wide attack surface. A Smarter platform holds the keys to every provider, database and SaaS API that its users connect, so the Secret table is the most valuable table it has. On a scale of one to ten I’d rate encrypting it a 9. Plaintext credentials in a database are a standing breach that only needs one leaked backup to happen. Ciphertext with a separately held key turns that backup into noise.

What it doesn’t cover matters just as much. It doesn’t protect against someone who controls a running pod, because the key is in the pod’s environment. It doesn’t protect a value after it has been decrypted and handed to the code that uses it. And it is only as good as the discipline of every other component that might record a value after decryption, such as logs, error reports and audit trails.

What it costs

Administration

Most of the burden is the key. FERNET_ENCRYPTION_KEY must be set explicitly, and must be identical on every pod and worker. If it isn’t set, the platform generates one and warns that existing encrypted data may become unreadable. That’s convenient on a laptop, but in a cluster it means pods that can’t read each other’s Secrets. Treat the key like the database password. Keep it in your secrets manager, inject it the same way everywhere, and back it up separately from the database. Smarter doesn’t rotate Fernet keys today, since it uses a single Fernet rather than a MultiFernet, so changing the key means re-encrypting every Secret. Plan for that before you need it.

Beyond the key, there’s little upkeep. manage.py get_secret and manage.py update_secret let an administrator check and replace a value from inside the application container without opening a shell to the database.

Money and performance

Fernet costs microseconds per call, and there is no external service to pay for or to wait on. That is the practical argument for it over a KMS call per read. There’s no network round trip, no per-request charge, and no dependency that can be down.

User experience

For users, a Secret is just another manifest, and other manifests refer to it by name. The trade-off is that a value can’t be pasted inline into, say, an LLMClient manifest. It has to be a Secret first. That is one more step, and it’s the step that keeps credentials out of version control.

Stability

The failure modes are all about the key. A wrong key fails at startup, which is what you want. A missing key on one pod among several, or a key changed without re-encrypting, fails at the first decrypt with a SmarterValueError, which surfaces as a resource that can’t reach its service. Both are deployment mistakes rather than runtime surprises, and both are prevented by injecting the key from one source.

Takeaways

  • Give credentials their own model, and have every other resource refer to it by name rather than hold a value.
  • Encrypt with Fernet (or another authenticated scheme) and store only the ciphertext. No plaintext column, not even a temporary one.
  • Validate the key’s shape at startup, and keep it out of the database and its backups.
  • Scope Secrets to an owner, keep values out of list responses, and don’t let one account’s resource use another account’s Secret.
  • Record reads with a queryset update and a signal, not with save().
  • Never log the ciphertext, and keep a test that proves it.
  • Decide how you’ll rotate the key before you need to.

Closing thoughts

Fernet is not exotic, and that’s its appeal. A Django project can adopt the same pattern in an afternoon. It takes a model with a binary field, an encrypt() class method, a get_secret() that decrypts on demand, and one key held outside the database. The rest of this post is the discipline that keeps the pattern honest, meaning the narrow read paths, the queryset update, and the log helper with its test. In my experience that discipline is the part that takes longer to get right.