Revision note (2026-09-15). The earlier version of this article shipped a revocation endpoint that anyone could call (no authentication), logged the raw key one paragraph after saying "Never log raw API keys", kept plaintext keys in the verifier's store, ran Flask with debug=True on 0.0.0.0, used datetime.utcnow() (deprecated since Python 3.12), described a dual-key grace period without any code for it, and cited "30 to 90 days" as a common rotation interval with no source. It also depended on AWS KMS and Vault for a mechanism that has nothing to do with either. This version replaces all of it with a small key store and Flask service, twenty tests, and a recorded session, and quotes the standards it leans on.
What "secure" has to mean for an API key
An API key is a bearer secret: whoever presents it is treated as the owner. The lifecycle problems, rotation and revocation, are worth nothing if the day-to-day handling is wrong, so the design below has five properties, each pinned by a test:
- The secret is 256 bits from the operating system's CSPRNG (
secrets.token_urlsafe(32), 43 characters), never derived from anything predictable. - The verifier stores only
SHA-256(secret). A dump of the key table does not yield usable keys. - Comparison uses
hmac.compare_digest, which the Python documentation describes as "designed to prevent timing analysis by avoiding content-based short circuiting behaviour". - Rotation issues a successor and gives the old key an expiry, so a client can switch at its own pace inside a window; at the end of the window the old key stops without anyone doing anything.
- Revocation is a flag checked on every verification. It takes effect on the next request, including on a key that is still inside its grace window.
Tested versions: Python 3.14.7, Flask 3.1.3, Werkzeug 3.1.8, pytest 9.1.1. No cloud services; the store is in memory and the tests inject a fake clock.
Key format: a public id and a secret
A key looks like dk.51cb3a77cf372d01._FGg6-uETGJumAa0V6G6p5TG2dDaykWRHx_Cg_SKiCM. The prefix lets secret scanners recognise the format in leaked text. The middle part is a 16-hex-character id that is public: it goes into logs, audit rows and error messages, and it lets the verifier look up one row instead of hashing the secret and scanning. Only the last part is secret.
def issue(self, owner: str, *, version: int = 1) -> tuple[str, KeyRecord]:
key_id = secrets.token_hex(ID_BYTES) # 8 bytes -> 16 hex chars, public
secret = secrets.token_urlsafe(SECRET_BYTES) # 32 bytes -> 43 chars, shown once
plaintext = f"{KEY_PREFIX}.{key_id}.{secret}"
record = KeyRecord(key_id=key_id, owner=owner, secret_hash=_digest(secret),
created_at=self._clock(), version=version)
with self._lock:
self._records[key_id] = record
return plaintext, record
The plaintext is returned once and never stored. test_issued_key_verifies_and_plaintext_is_not_stored asserts that the stored digest equals sha256(secret) and that the secret does not appear anywhere in the store's state; test_issued_keys_are_unique_and_long issues 1,000 keys and checks they are distinct and 43 characters long.
Why SHA-256 and not a password hash? A password hash exists to slow down guessing of low-entropy inputs. A 256-bit random secret is not guessable in the first place, so the slow hash buys nothing and costs a few milliseconds per request. This is a reasoning argument, not a measurement; if your keys are shorter or chosen by users, the calculus changes and you are back to password rules.
Verification
def verify(self, presented: str) -> KeyRecord | None:
parsed = _parse(presented) # prefix, 16-hex id, secret; else None
if parsed is None:
return None
key_id, secret = parsed
with self._lock:
record = self._records.get(key_id)
if record is None:
hmac.compare_digest(_digest(secret), _digest("x")) # same work as a real lookup
return None
if not hmac.compare_digest(_digest(secret), record.secret_hash):
return None
now = self._clock()
if record.revoked_at is not None:
return None
if record.expires_at is not None and now >= record.expires_at:
return None
return record
The order matters: the secret is checked before the status, so a caller with the wrong secret learns nothing about whether the id is revoked or expired. Five malformed inputs, a wrong secret on a valid id, and an unknown id are all rejected in tests. The Python hmac documentation notes that compare_digest can still leak the length and type of its inputs; here both sides are always 32-byte digests, so there is nothing to leak. Timing resistance was not measured in this lab; the function is used because the documentation recommends it for exactly this comparison.
Rotation with an overlap window
Rotation is not "generate a new key and delete the old one". A client that reads its key at startup would fail until redeployed. Instead, rotate() issues a successor and sets an expiry on the old key:
def rotate(self, key_id: str, *, grace_seconds: float) -> tuple[str, KeyRecord]:
with self._lock:
old = self._records[key_id]
if old.revoked_at is not None:
raise ValueError("cannot rotate a revoked key")
if old.successor_id is not None:
raise ValueError("key was already rotated")
plaintext, new = self.issue(old.owner, version=old.version + 1)
now = self._clock()
with self._lock:
old.successor_id = new.key_id
old.expires_at = now + grace_seconds
return plaintext, new
test_rotation_keeps_old_key_valid_during_grace_then_expires_it rotates with a 3,600 s window, advances the fake clock to 3,599 s and verifies both keys, then advances one more second and verifies that only the new one works. A second rotation of the same key is refused (ValueError, HTTP 409 in the service), because a key that already has a successor should not sprout a second one; rotate the successor instead. grace_seconds=0 is allowed and cuts the old key immediately.
From the outside, with a 5 s window for the demo:
$ POST /keys/51cb3a77cf372d01/rotate {"grace_seconds":5}
{"api_key":"dk.ee58d98cd1e3e135.mMjHFeZlEFTEyTyNM3yALU6DqDyCzJiZBtw55bPNx7c","key_id":"ee58d98cd1e3e135","old_expires_at":1789463098.805639,"old_key_id":"51cb3a77cf372d01","version":2}
$ GET /whoami old key, inside the grace window
{"key_id":"51cb3a77cf372d01","owner":"svc-a","version":1} [200]
$ GET /whoami new key
{"key_id":"ee58d98cd1e3e135","owner":"svc-a","version":2} [200]
$ POST /keys/51cb3a77cf372d01/rotate again
{"error":"key was already rotated"} [409]
$ (5.5 s later) GET /whoami old key
{"error":"invalid api key"} [401]
$ GET /whoami new key still works
{"key_id":"ee58d98cd1e3e135","owner":"svc-a","version":2} [200]
The version field in /whoami is how a client can tell which key it is running on, and how an operator can see from access logs which clients have not moved yet before the window closes.
Revocation is immediate and beats the window
def revoke(self, key_id: str) -> KeyRecord:
with self._lock:
record = self._records[key_id]
if record.revoked_at is None:
record.revoked_at = self._clock()
return record
That is the whole operation, and it is idempotent (a second revoke keeps the first timestamp). What matters is the check in verify(): test_revocation_is_immediate_and_beats_the_grace_window rotates a key, confirms the old key still works inside its window, revokes it, and asserts the very next verify() returns None while the successor still works; then it revokes the successor too. In the recorded session:
$ POST /keys/ee58d98cd1e3e135/revoke
{"key_id":"ee58d98cd1e3e135","revoked_at":1789463099.459208} [200]
$ GET /whoami new key after revoke
{"error":"invalid api key"} [401]
This is the answer to the question the earlier version raised and did not answer, "what if a key's compromise time is unknown": revoke now, rotate the rest, and do not wait for a window. NIST SP 800-57 Part 1 Rev. 5 puts it as a requirement (Section 5.5): "When a key is compromised, all use of the key to apply cryptographic protection to information … shall cease, and the compromised key shall be revoked."
The service, and what its log contains
The Flask app has three admin routes, protected by a static admin token compared with compare_digest (a lab shortcut; in production this is your operator identity), and a before_request hook that verifies X-API-Key for everything else:
@app.before_request
def authenticate():
if request.path.startswith("/keys") or request.path == "/health":
return None
record = store_().verify(request.headers.get("X-API-Key", ""))
if record is None:
return jsonify(error="invalid api key"), 401
g.key = record
return None
Every audit line names the key id and only the key id:
2026-09-15 18:04:53,720 INFO apikeys: key issued id=51cb3a77cf372d01 owner=svc-a
2026-09-15 18:04:53,805 INFO apikeys: key rotated old=51cb3a77cf372d01 new=ee58d98cd1e3e135 grace=5.0s
2026-09-15 18:04:59,459 INFO apikeys: key revoked id=ee58d98cd1e3e135
test_logs_never_contain_the_secret captures the log across issue, rotate and revoke and asserts the secret is absent and the id is present; a grep -c of both secrets over the recorded server log returned 0. This is what makes the public id worth having: you can correlate an incident to a key without ever writing the key down.
What the earlier version's code did when run
The earlier revocation endpoint (a Flask route over a dictionary of plaintext keys) was run as written, with only the app.run(...) line removed, through Flask's test client on Python 3.14.7:
POST /revoke_key (no auth header) valid_key_1 -> 200 {'message': 'API key revoked', 'status': 'success'}
POST /revoke_key valid_key_1 again -> 404 {'message': 'API key not found or already revoked', 'status': 'error'}
POST /revoke_key does_not_exist -> 404 {'message': 'API key not found or already revoked', 'status': 'error'}
log output: INFO article-flask: API key revoked: valid_key_1
store contents: {'valid_key_1': False, 'valid_key_2': True}
Anyone who can reach the endpoint can revoke any key, which turns the revocation service into a denial-of-service endpoint for every client. The raw key is in the log. The store maps plaintext keys to booleans, so a copy of it is a copy of every credential. The app.run(host='0.0.0.0', port=5000, debug=True) line that was removed would, per the Flask documentation, expose the interactive debugger: "The debugger allows executing arbitrary Python code from the browser. It is protected by a pin, but still represents a major security risk. Do not run the development server or debugger in a production environment." And the rotation script's datetime.datetime.utcnow() warns on 3.14:
DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
The rotation script itself was not run; it creates a boto3 KMS client at import time and needs AWS credentials. Nothing in this article depends on KMS or Vault. Encrypting the key at rest in a secret store is a reasonable thing to do for the copy the client holds; it does not change what the verifier must do, which is the subject here.
How often to rotate
The earlier version said "common intervals range between 30 to 90 days" and cited NIST SP 800-57. The document does not say that. What it says (Section 5.3) is that a cryptoperiod is "the time span during which a specific key is authorized for use", that it "limits the amount of exposure if a single key is compromised" and "limits the period within which information may be compromised by inadvertent disclosure", and (Section 5.3.6) that its suggested periods "are only rough order-of-magnitude guidelines; longer or shorter cryptoperiods may be warranted depending on the application and environment". Its Table 1 lists a symmetric authentication key's originator-usage period as under two years. An API key is not exactly a MAC key, so read that as an upper bound on how long a static credential should be allowed to live, not as a schedule.
A defensible schedule comes from two questions: how long would you be comfortable with a leaked key working, and how long does it take your slowest client to pick up a new one. The first bounds the interval from above; the second bounds the grace window from below. The OWASP Secrets Management Cheat Sheet gives the same reasoning in one line: "You should regularly rotate secrets so that any stolen credentials will only work for a short time." With rotation automated and a grace window in place, the cost of rotating often is small, and the mechanics above are the same whether you run them monthly or on every deploy.
What this does not cover
- Persistence. The store is an in-memory dictionary behind a lock; the record has the columns a table would need (
key_id,owner,secret_hash,created_at,expires_at,revoked_at,successor_id,version). - Multiple verifier processes and caching. If verifiers cache lookups, revocation latency is the cache TTL; nothing here measures that.
- Timing-attack resistance as a measurement.
compare_digestis used on documentation grounds. - Distribution of the new key to the client, and secret stores. The service returns the plaintext once over the admin channel; getting it into the client is deployment-specific.
- Scoping and permissions attached to a key, rate limiting per key, and per-key usage metering.
Reproduce it
The lab is examples/api-key-rotation in the site repository.
python3.14 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python -m pytest -q # 20 passed
ADMIN_TOKEN=lab-admin-token .venv/bin/flask --app app run --port 8099
Sources
- Python: hmac.compare_digest
- Python: secrets module
- Python: datetime.utcnow deprecation
- Flask 3.1 quickstart: Debug Mode
- NIST SP 800-57 Part 1 Rev. 5, Recommendation for Key Management: Part 1 – General (Sections 5.3, 5.3.6, 5.5, Table 1)
- OWASP Secrets Management Cheat Sheet
- OWASP API Security Top 10 2023 (API2:2023 Broken Authentication is the nearest item; there is no rotation item)
