django-rest-knox: Smarter’s API Keys Are Stored as Digests and Named by a UUID
Smarter‘s API keys are django-rest-knox tokens, extended into a model called SmarterAuthToken. The database keeps only a SHA-512 digest of each key and its first 15 characters. Every key has a separate random UUID that serves as its public name, and keys can only be issued to staff users. This post describes what each of those choices protects against, how they fit together in the code, and what they ask of the people who run and use the platform.
An API key on an LLM platform is not a low-value credential. A Smarter key can apply and delete manifests, deploy LLMClients that create DNS records and certificates, call the Proxy that spends provider money, and read whatever its owner can read. Keys get pasted into CI secrets, .env files and scripts, which is where keys usually leak from. The design question is what happens in the other common case, when it’s the platform’s own data that leaks.
The three properties
| Property | Protects against | Where |
|---|---|---|
| Only a digest is stored | A database dump, backup or replica that becomes a list of working keys | knox, and SmarterAuthToken.validate_token() |
| A UUID is the public identifier | URLs, logs and manifests that carry something derived from the key | SmarterAuthToken.key_id |
| Staff only | Every user of an account minting long-lived credentials | SmarterAuthToken.save() |
Each one is small. Together they mean the key itself exists in exactly two places, the caller’s configuration and, for the length of one request, the server’s memory.
Stored as a digest
Knox generates a key from 32 bytes of os.urandom, written as 64 hexadecimal characters. Smarter’s manager stores two things derived from it, from lib/drf/models.py.
token = prefix + crypto.create_token_string()
token_key = token[: CONSTANTS.TOKEN_KEY_LENGTH]
digest = crypto.hash_token(token)
...
auth_token = self.model(
token_key=token_key,
digest=digest,
user=user,
expiry=expiry,
...
)
auth_token.save()
# never log token_key: it is the first characters of the token itself.
digest is a SHA-512 hash of the key, knox’s default, which Smarter doesn’t override. token_key is the first 15 characters, kept in the clear so that a lookup can find candidate rows without hashing against every row in the table. The remaining 49 hexadecimal characters, 196 bits, are known only to the holder.

A fast hash is the right choice here, and it’s worth saying why, because the instinct with passwords is the opposite. A password is short and chosen by a person, so it has to be stretched with bcrypt or Argon2 to make guessing expensive. A knox key is 256 bits of randomness. There is nothing to guess, so stretching adds latency to every API request and no security. SHA-512 turns a stolen table into something that can’t be replayed, and that’s all a digest has to do.
Verification compares digests in constant time. This is from the same model.
def validate_token(self, token: str) -> bool:
"""Return True if token is this api key's token, which is stored only as its digest."""
if not token or token[: CONSTANTS.TOKEN_KEY_LENGTH] != self.token_key:
return False
return hmac.compare_digest(crypto.hash_token(token), self.digest)
The request path goes through knox’s authenticate_credentials(), which does the same comparison with compare_digest, and Smarter’s subclass in token_authentication.py then checks that the key is active. A deactivated key fails with a 401, just as an unknown key does. Its log lines and signals carry the key through mask_string(), never in full.

The consequence that users notice is that a key is shown once. The management command says so as it prints it.
self.stdout.write(self.style.SUCCESS(f"API key: {token_key}"))
...
self.stdout.write(
self.style.WARNING(
"This API key is only displayed once and cannot be recovered if lost. Store it in a secure location."
)
)
The web console’s create-key endpoint returns the key in its response the same way, once. A lost key isn’t recovered. It’s replaced.
A digest protects the token table, and only that. Anything else that records request headers, such as an access log, a reverse proxy’s debug log, an APM agent or an audit trail, sees the key in full in Authorization and has to drop or mask it. Otherwise it quietly undoes the hash. That rule applies to every system that adopts this pattern, and it’s the one I’d check first in a review.
Named by a UUID
A knox token has no integer id. Its primary key is the digest. That’s a problem for everything that has to name a key, such as URLs, log lines, the web console, manifests, and a deployed LLMClient’s list of the keys it accepts. Using the digest as the name would put a value derived from the key into every one of those places, and using token_key would put the first 15 characters of the key itself there.
SmarterAuthToken adds a field for that job.
key_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
It then makes that field the identifier everywhere a Smarter resource would normally use its id.
@property
def id(self) -> str: # pylint: disable=invalid-name
"""
Returns the token's public identifier, its key_id, as a string.
A knox AuthToken has no integer id: its primary key is its digest. The api, the
urls and the web console identify a token by its key_id instead.
"""
return str(self.key_id)
The key’s record locator, the string that other parts of the platform use to refer to it, is built from key_id too, and its manifest page is /authtoken/<key_id>/. The UUID is random and unrelated to the key, so it can appear in a URL, a log line or a support ticket without risk. When a display needs something more recognizable, identifier returns the digest masked, never the key.
Cloning follows the same rule. clone() copies a key’s name, description and tags, but asks the manager for a new token, digest and key_id. The generic clone of a Smarter resource would otherwise copy the original’s digest and key_id, and no two keys may share either.
Staff only
The last property is a business rule enforced in the model, where no view can forget it.
def save(self, *args, **kwargs):
if not self.user.is_staff:
raise SmarterBusinessRuleViolation("API Keys can only be created for staff users.")
In Smarter, staff means an account admin. Ordinary users of an account work through the web console with a session. They can use everything the console offers them, but they can’t mint a credential that outlives their session and can be copied to another machine. The manifest broker for API keys adds two more checks. Only staff may apply an API key manifest, and only a superuser may create or change a key that belongs to a superuser. So an account admin can’t issue themselves a key that acts as someone more privileged.
API keys also scope access to deployed LLMClients. When a key is attached to an LLMClient, that LLMClient’s API requires one of its attached keys. When none is attached, it accepts anonymous chat, which is what a public website’s chat widget needs.
Lifecycle
A key can be active or inactive, and deactivating it takes effect on the next request. That’s the normal way to end a key’s life, because it keeps the record and its history. Each successful authentication stamps last_used_at, written with a queryset update rather than save(), so that authenticating a request doesn’t invalidate the key’s cached objects. That makes stale keys easy to find.
The model also has knox’s expiry, and knox deletes an expired key the first time someone tries to use it. Smarter’s manager creates keys with no expiry unless the caller passes one, and none of the built-in ways to issue a key sets one today. In practice, keys last until someone deactivates them, which puts that job on whoever administers the account.
How much does it matter?
The attack surface is every path by which the platform’s database can leave the cluster, including backups, replicas, a developer’s restore, an SQL injection anywhere in the application, a misconfigured read-only user. Each of those happens to real systems every year. With plaintext keys, each one is a complete compromise of every API user. With digests, it’s a compromise of none of them. On a scale of one to ten I’d put the digest at an 8, because the cost is close to zero and the failure it prevents is total. The UUID and the staff rule are smaller, perhaps a 5 and a 6. They narrow how keys leak and who can create them, rather than defending against a whole class of attack.
None of it helps once a key leaks from the caller’s side, and that is still the most common way keys leak. Deactivation, last_used_at, and keys issued per person or per program rather than shared are what limit the damage then.
What it costs
Administration
There is almost nothing to maintain. There is no key-encryption key to rotate and no hashing parameters to tune. The ongoing work is hygiene. Issue one key per person or program, give it a name and description that say what it’s for, and deactivate keys whose last_used_at has gone quiet. Since keys don’t expire by default, that review is the only thing that retires them.
Money and performance
One SHA-512 hash per request, measured in microseconds, and one indexed lookup on token_key. The last_used_at stamp adds a single-column update per authentication.
User experience
The visible costs are the ones every hashed-key system has. A key is shown once, and a lost key has to be replaced, not retrieved. The staff rule means a non-admin who wants to script against the API has to ask an admin for a key. I think that’s the right default for a platform whose keys can spend money and change DNS, but it’s a deliberate friction, not an accident.
Stability
The design has no moving parts that can fail independently. There is no external secrets service on the authentication path, and keys stay valid across restarts and new pods, because validating one needs only the database. A deactivated key fails cleanly with a 401, and an inactive key can be reactivated with no data lost.
Takeaways
- Store API keys as digests. For long random keys, a fast hash such as SHA-512 is enough, and a password hash only adds latency.
- Compare digests with
hmac.compare_digest, and look rows up by a short plaintext prefix rather than hashing against the whole table. - Show a key once, and say so on the screen.
- Give each key a random public identifier for URLs, logs and references, and never use the digest or a prefix of the key as its name.
- Generate a new key, digest and identifier when you clone a key. Don’t copy them.
- Enforce who may hold keys in the model’s
save(), not in a view. - Track
last_used_atwith a queryset update, and review it. - Drop or mask
Authorizationeverywhere that records requests, or the digest protects nothing.
Closing thoughts
django-rest-knox already does the hard part, and Smarter adds three small things to it, which are a UUID to name keys by, a rule about who may hold them, and the discipline of never letting the key, its prefix or its digest stand in for its name. Any Django project with an API can adopt the same model in a day. The part that takes longer is the last takeaway, auditing everything around the token table for places that copy the header the table was built to forget.
