Vibe-specifying Coppice
Has it really been two and a half years since I last wrote on this blog? Wow. Sorry about that, I guess!
This has been quite the few years, though! We’ve made a lot of progress with the DNP CSTF on the new DNP3 Security Layer (no longer called DNP3-SAv6) and on the Authorization Management Protocol, introducing post-quantum (or quantum-safe) crypto into the former and getting a lot closer to an Authority spec on the latter. I’ve also been involved in a number of interesting projects, most of which I can’t talk about on this public forum, and the world has mostly recovered from COVID and is now setting itself up for a whole new, but man-made, set of calamities.
None of that is what I want to talk about here – this is a Cybersecurity blog, not a lament-the-state-of-the-world blog.
Coppice
This morning, I had an idea. That, in itself, happens often enough, but this time, I thought I’d write about it and put it on one of my blogs (the others are in a similar state of abandon as this one, so don’t worry if you don’t follow those already).
A recurring theme with micro-services is the need for security and the need for availability. Both of those problems have been solved many times and in many different ways, but it feels like everyone has to re-invent the wheel every time they run into this issue. They all invariably come to one of a small set of solutions. Among those solutions are the use of service accounts and bearer tokens.
So, this morning, as I got out of the shower, I picked up my phone and recorded a voice memo.
One of the nice things with recent phones is that they automatically create a transcript of your voice memos, so I grabbed some coffee, went to my home office in the basement, and spun up a Visual Code to run this as a “vibe coding” project.
Here’s the transcript, edited for where Siri didn’t understand what I was saying. Before you read it (and feel free to skip it): it’s a bit of a ramble.
Okay, uhm. Schema: version, colon, key ID, colon, nonce, uhm, output from an HKDF. The way to calculate that is to take a secret, which stays only on the server side, assign a random nonce and, uhm, use that as the salt. Feed in the secret, the other fields, get the output, add that to the token.
So on validation, we look up the key ID, we make sure that the token isn’t expired, and we run the HKDF. Output validated against what’s in the token. If it’s the same, the token is valid.
We wouldn’t heat (sic.) a token that is already expired, but adding it to the token itself allows the client side to validate whether expiry has happened yet as well, which would be a nice feature to have.
Uhm, yeah, so, the token doesn’t contain anything secret because the actual secret is only stored server-side, but it is a secret in the sense that it allows the bearer access to whatever, uhm, the roles or privileges assigned to the master key are, so it should be kept securely, because it itself is a secret.
Okay, so, one question, maybe, is how is this better than JWT? And how is it better than a signed JWT that simply contains the claims signed on the server side.
It isn’t necessarily, it’s just different.
Use cases would be a bit different as well. So, one, for example, is. It’s more compact: it’s not a whole JWT. It’s not a JSON document. The same type of information, but it should take less space in a format like this.
The other is, uhm, you have complete centralized control over all the tokens generated by (the service), in the sense that you can assign the roles, rather than to the token itself, you can assign it to the master key. (…) When you rotate that key, all of the tokens associated with that key automatically expire. (…)
If you, uhm, if you delete the key, all of (the tokens generated with that key) would automatically go away as well, but if you assign new permissions to the key, you automatically assign new permissions to every token from that key. And that is something you can’t do with JWT.
Uhm.
You’ll still want to exchange it for a JWT though. So if you use it to log in on a service account, you should get a JWT back. That is much shorter lived because these tokens should live about a year. And it’s a shared secret so they, uhm, one year expiry seems reasonable. But the JWT that comes back should be, (…), perhaps refreshable access token that gives you the exact access that the master key would give you, but only lives for, like, (…), 10 minutes or an hour or whatever is appropriate for a, uhm, session timeout, in the system.
Right? It does allow you to (…). That scheme where you present a token, you get a new token back, a JWT back, that’s fairly standard. Uhm, but it also allows you to let that token live for, you know, a significant amount of time. And then, uhm, you know, so a year, for example, uhm, and then just hold it for whatever time it takes.
Exchanging it for a JWT token could be an action hidden from the client side. So, for example, if I send a message to my service that could (…) bearer token that is described in the entry point of that API, very well, validate that token service-side, get a JWT token back and associate it with the internal message that also gets the correlation ID. And, you know, have that go through the system.
Uhm, so, that JWT doesn’t need to be visible to the client side, but it would basically allow every, uhm, microservice in the system to validate it offline that, uhm, yes, the token has been, uhm, validated and privileges associated with that authorization token, are, you know, valid and internal to the system. So the rest of the system would only see the JWT token entry point, get the bearer token, and exchange it for JWT before passing it on into the system.
So that means one less handshake for, uhm, for the client. But also potential high availability for everything else because only the entry point would need access to the service that validates the token.
Vibe speccing
So, I fed this into Claude and asked it to write up a spec. I was honestly a bit surprised at how decent a job it did. I had used Claude before to generate code and have often compared it to an enthusiastic intern with good Internet access and a can-do attitude, who imbibes “the good stuff” when you let him but this time, with just the transcript and a prompt, it understood the token format, HKDF key derivation, the data mode, the token lifecycle, and the hidden-from-the-client JWT token exchange flow.
It did add a token ID to the spec that I hadn’t asked for, which added a requirement to have a server-side list of generated tokens that the roles were associated with. It used that token ID as the nonce, rather than an actual nonce.
So, I told it to fix that: remove the server-side database tracking the tokens, and replace the token ID with a nonce. It understood the instructions well, but the spec it produced still had a section describing the data in a server-side database that no longer existed.
While it was generating that response, though, my thoughts had drifted to auditing: if I remove the server-side database tracking tokens, I also lose the traces I have of those tokens. So I told it:
Audit requirements are missing from the spec. Let’s add that: there should be an audit trail for any action taken on these tokens, including time stamps and the unique ID of who took the action. The audit trail should survive the master key by at least a few years.
This triggered its first questions: How long should it retain the audit trail after key duration? (configurable, specify a recommended minimum only). Should the audit trail be accessible through the API? (No, SIEMS only). What actor identity should be captured? (Human or principal, whichever is available.)
It added a few new sections to the spec. I re-read its output, told it to clean up a few things (e.g. it had added “rounds” to the parameters, I told it to remove it, it asked if I wanted to move from HKDF to PBKDF2 so it could keep the parameter, I said no).
Finally, it had created the spec as a Word document. This blog doesn’t do Word well, and I wanted a Mermaid graph for a sequence diagram, so I asked it to re-generate the spec in Markdown and generate a README for a repo I would put it in. It did both, and I ran out of free tokens for the morning.
The spec
Here’s the specification it came up with. It’s actually fairly close to what I might have written…
Coppice — Technical Specification
Version 1.3 · Draft
Table of Contents
- Overview
- Token Format
- Key Derivation (HKDF)
- Server-Side Data Model
- Token Lifecycle
- Audit Trail
- JWT Exchange Flow
- API Definitions
- Error Codes
- Security Considerations
1. Overview
This document specifies a compact, server-authoritative authentication token scheme backed by HKDF key derivation. Tokens are opaque to clients, contain no secrets, and support centralized revocation, permission inheritance, and short-lived JWT exchange at API gateway boundaries.
Key design goals:
- Compactness — smaller on-wire footprint than a signed JWT containing equivalent claims.
- Stateless token issuance — tokens are issued without writing any per-token record to the database. The server stores only a master key record per logical credential, not one record per issued token.
- Server-authoritative permissions — roles are stored on the master key record, not embedded in the token. Any permission change propagates instantly to every token derived from that master key.
- Centralized revocation — revoking or deleting a master key immediately invalidates all tokens derived from it.
- No secret exposure — the system secret never appears in the token or in any database record.
- Client transparency — long-lived tokens are exchanged for short-lived JWTs at the API gateway, so downstream microservices never see the raw token.
2. Token Format
2.1 Structure
A token is a colon-delimited string, Base64url-encoded for safe transmission. The nonce provides per-token uniqueness, eliminating the need to store any per-token state server-side.
1 2 3 4 |
version:masterKeyId:nonce:expiry:hash Where each segment is Base64url-encoded before joining. The final token = Base64url( version + ":" + masterKeyId + ":" + nonce + ":" + expiry + ":" + hash ) |
| Field | Example | Description |
|---|---|---|
version |
1 |
Integer schema version. Incremented on breaking changes to allow multi-version validation. |
masterKeyId |
mk_7f2a9b |
Identifies the master key record on the server. Determines which secret and permission set to use during validation. |
nonce |
r4Xk9p... |
Cryptographically random bytes (16 bytes / 128 bits recommended) generated at issuance. Ensures each token produces a unique HKDF output. Never reused. |
expiry |
1798761600 |
Unix timestamp (time_t) at which the token expires. Allows client-side pre-validation without a round-trip. |
hash |
Ax9k...Zp4= |
HKDF-derived pseudorandom output binding all fields to the server secret. Used to validate the token server-side. |
2.2 Wire Format
The assembled token must be Base64url-encoded (RFC 4648 §5, no padding) before being transmitted in an Authorization header or equivalent:
1 |
Authorization: Bearer <base64url(version:masterKeyId:nonce:expiry:hash)> |
3. Key Derivation (HKDF)
3.1 Algorithm
Token hashes are produced using HKDF (RFC 5869) with HMAC-SHA-256. The nonce serves as the HKDF salt, providing per-token randomness without any server-side per-token storage. The info string binds the derivation to all other token fields, ensuring that a hash cannot be transplanted to a token with different parameters.
1 2 3 4 5 6 |
HKDF-SHA256( IKM = systemSecret, // high-entropy server-side secret; never leaves server salt = nonce, // random bytes from token; per-token uniqueness info = version || masterKeyId || expiry, // pipe-delimited ASCII L = 32 // 32-byte output ) |
3.2 HKDF Inputs
| Field | Type | Required | Description |
|---|---|---|---|
version |
string | Yes | Schema version from the token. Included in info to prevent cross-version hash reuse. |
masterKeyId |
string | Yes | Identifies the master key record. Binds the derivation to a specific logical credential and its secret. |
nonce |
bytes | Yes | 16 random bytes generated at issuance, used as the HKDF salt. Ensures each issued token has a unique hash even if all other fields are identical. |
expiry |
integer | Yes | Unix timestamp from the token. Including expiry in info prevents constructing a valid hash for a different expiry without knowing the secret. |
4. Server-Side Data Model
4.1 Master Key Record
One master key record is created per logical credential (e.g. per API client, per service account, or per tenant integration). No per-token records are stored. All tokens derived from the same master key share its permissions and revocation status.
1 2 3 4 5 6 7 8 |
MasterKey {
masterKeyId string // primary key; random, e.g. "mk_7f2a9b"
version int // token schema version in use for this key
tenantId string // scope to tenant / org
permissions []string // roles / scopes granted to all tokens from this key
revokedAt int64? // null = active; set to immediately revoke all tokens
createdAt int64
}
|
Because there are no per-token records, a single master key record controls arbitrarily many issued tokens. Revoking the master key invalidates all of them simultaneously. Individual token revocation is not supported by this scheme; if per-token revocation is required, issue one master key per token.
5. Token Lifecycle
5.1 Issuance
Token issuance is stateless with respect to per-token storage. The server reads the master key record but writes nothing.
- Look up the MasterKey record for the requested
masterKeyId(must exist and not be revoked). - Generate a cryptographically random nonce (16 bytes, from a CSPRNG).
- Set expiry to
now + TTL(default: 1 year). TTL may be overridden in the request. - Run HKDF with nonce as salt and
version || masterKeyId || expiryas info. - Assemble and Base64url-encode the token:
version:masterKeyId:nonce:expiry:hash. - Return the token to the caller. No database write occurs.
5.2 Validation
- Decode the Base64url token; split on
:to extractversion,masterKeyId,nonce,expiry,hash. - Reject immediately if
expiry < now(client-side pre-check is also possible using the expiry field). - Look up MasterKey by
masterKeyId; return 401 if not found orrevokedAtis set. - Verify the version field in the token matches the version on the master key record (prevent cross-version hash reuse).
- Load the system secret; re-run HKDF using nonce as salt and masterKeyId with record parameters.
- Compare derived hash to token hash using a constant-time comparison; return 401 on mismatch.
- Return the
permissions[]from the master key record to the caller.
5.3 Expiry
Expiry is encoded in the token itself, allowing client-side pre-validation without a server round-trip. It is also covered by the HKDF info string, so it cannot be altered without invalidating the hash. Tokens past their expiry are rejected at validation time; no cleanup is needed.
5.4 Revocation
Setting revokedAt on a MasterKey record immediately invalidates all tokens ever issued from that key, since every validation performs a live lookup of the master key record. Deleting the record has the same effect. Because no per-token records exist, individual token revocation is not possible — revocation is always at the master key level. To approximate per-token revocation, issue a dedicated master key for each token.
6. Audit Trail
Every action that creates, modifies, validates, revokes, or reads a master key — or that issues, validates, or exchanges a token — must produce an immutable audit event. The audit trail is the authoritative record of what happened, when, and who caused it. It must survive the deletion of any master key by a configurable retention period, with a recommended minimum of 7 years.
6.1 Audit Event Schema
Each audit event is a single immutable record written atomically with the action it describes. Events must never be modified or deleted within the retention window.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
AuditEvent {
eventId string // globally unique, e.g. UUID v4
eventType string // see Section 6.2
timestamp int64 // Unix timestamp (millisecond precision)
masterKeyId string? // the key involved; null for system-level events
tenantId string? // tenant scope of the affected key
actor {
principalId string // always present: service account or API credential ID
userId string? // present when a human operator is identified
ipAddress string? // source IP of the request
userAgent string? // client user-agent string, if available
}
outcome string // "success" | "failure"
failureReason string? // populated on failure; maps to error codes in Section 9
metadata object? // event-type-specific fields; see Section 6.3
}
|
The actor block must always include at least one identity. If a request carries both a service credential and a human operator identity (e.g. via a forwarded session header), both are recorded. If only one is available, the other field is omitted — never defaulted to a placeholder.
6.2 Auditable Event Types
| eventType | Triggered By | Notes |
|---|---|---|
master_key.created |
POST /master-keys |
Includes initial permissions in metadata. |
master_key.revoked |
DELETE /master-keys/{masterKeyId} |
All derived tokens invalidated from this point forward. |
master_key.permissions_updated |
PUT /master-keys/{id}/permissions |
Logs both old and new permission sets. |
master_key.looked_up |
GET /master-keys/{masterKeyId} |
Read-access audit; always logged regardless of outcome. |
token.issued |
POST /tokens/issue |
Logs masterKeyId and expiry. Token and nonce are never logged. |
token.validated |
POST /tokens/validate |
Logs outcome and failure reason if applicable. |
token.exchanged |
POST /tokens/exchange |
Logs that a JWT was minted. JWT string is never logged. |
6.3 Event-Type Metadata
The metadata field carries structured data specific to each event type.
master_key.created / master_key.permissions_updated
1 2 3 4 |
metadata: {
permissions: string[] // new permission set
previousPerms: string[]? // prior set (permissions_updated only)
}
|
token.issued
1 2 3 4 5 |
metadata: {
expiry: int64 // token expiry as Unix timestamp
ttl: int64 // requested TTL in seconds
// token string and nonce are never logged
}
|
token.validated / token.exchanged
1 2 3 4 |
metadata: {
expiry: int64 // expiry from the presented token
failureReason: string? // see error codes in Section 9
}
|
6.4 What Must Never Be Logged
The following values must never appear in any audit event, log line, or diagnostic output, regardless of log level or environment:
- Token strings — the Base64url-encoded credential returned to the client.
- Nonce values extracted from or used to construct tokens.
- System secret bytes or any value derived from them.
- JWT strings issued by the exchange endpoint.
Audit events record identifiers and outcomes only — never credentials or key material.
6.5 Retention Requirements
Audit events must be retained for the configured minimum period, measured from the later of: (a) the event timestamp, or (b) the revocation or deletion timestamp of the associated master key. This ensures the complete history of a credential remains available for investigation even after the credential itself is gone.
| Parameter | Requirement |
|---|---|
| Minimum retention | Configurable; recommended floor of 7 years for regulated environments. Must be set at deployment time and enforced automatically. |
| Retention anchor | The later of: event timestamp or master key deletion/revocation date. Events for a key deleted after 6 years must still be retained for the full configured period from that deletion date. |
| Immutability | Events must be written to an append-only store. No update or delete operations are permitted within the retention window, including by operators. |
| Availability | Audit logs must remain queryable (not merely archived) for the full retention period. |
| Clock accuracy | All timestamps must be sourced from a synchronized clock (NTP or equivalent). Clock skew must not exceed 1 second. |
6.6 Delivery and Integration
Audit events are not exposed through the Token Service API. Consumers access them out-of-band through the deployment’s log or event infrastructure. The Token Service must support at least one of the following delivery mechanisms:
- Structured JSON log output to stdout — one event per line, suitable for ingestion by a log aggregator (e.g. Fluentd, Logstash, or a cloud-native log router).
- Direct publish to an append-only event stream (e.g. Apache Kafka, AWS Kinesis, or Azure Event Hubs) for real-time SIEM ingestion.
- Write to a dedicated append-only audit table in a database instance separate from the master key store, accessible to authorised compliance tooling only.
The Token Service must guarantee at-least-once delivery and must emit an operational alert if the audit sink becomes unreachable. Token operations must not silently succeed with audit events dropped. If the audit sink is unavailable, the service should either fail the operation or queue the event with bounded retries and alert.
6.7 Access Control on Audit Logs
Read access to the audit log is a distinct privilege, granted independently of the Token Service management credential. Write access belongs exclusively to the Token Service process. No other system or operator may append to, modify, or delete records within the retention window. Human operators granted read access must not have delete access during the retention period.
7. JWT Exchange Flow
Long-lived tokens are not forwarded into the internal microservice mesh. Instead, the API gateway validates the token and issues a short-lived JWT that downstream services can verify offline using the gateway’s public key.
7.1 Flow
See jwt-exchange-flow.mermaid for the sequence diagram. In summary:
- Client sends a request with
Authorization: Bearer <token>. - API Gateway calls
POST /tokens/validate. - Token Service checks token expiry (no DB needed).
- Token Service looks up MasterKey, checks
revokedAt. - Token Service re-derives the HKDF hash and compares (constant-time).
- Token Service returns
{ masterKeyId, tenantId, permissions[] }. - API Gateway mints a short-lived JWT (exp: ~1 hour, signed with gateway private key).
- API Gateway forwards the request with the JWT to downstream services.
- Downstream services verify the JWT signature offline — no Token Service call.
The JWT is invisible to the external client. It exists only within the service mesh for the duration of the request.
7.2 JWT Claims
1 2 3 4 5 6 7 8 |
{
"sub": "<masterKeyId>",
"tid": "<tenantId>",
"scope": ["read:data", "write:data"],
"iat": 1700000000,
"exp": 1700003600,
"jti": "<random nonce>"
}
|
8. API Definitions
The Token Service exposes the following REST endpoints. All requests and responses use application/json. All management endpoints require a service-to-service credential (e.g. mTLS or an internal bearer token).
8.1 Endpoint Summary
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/master-keys |
Service credential | Create a master key record |
GET |
/master-keys/{masterKeyId} |
Service credential | Retrieve master key metadata |
PUT |
/master-keys/{masterKeyId}/permissions |
Service credential | Update permissions on a master key |
DELETE |
/master-keys/{masterKeyId} |
Service credential | Revoke all tokens for a master key |
POST |
/tokens/issue |
Service credential | Issue a new token from a master key (no DB write) |
POST |
/tokens/validate |
None | Validate a token; returns permissions |
POST |
/tokens/exchange |
Bearer token | Exchange a long-lived token for a JWT |
8.2 POST /master-keys — Create Master Key
Creates a new master key record. A master key is the logical credential from which any number of tokens can be issued. No tokens are produced by this call.
Request
| Field | Type | Required | Description |
|---|---|---|---|
tenantId |
string | Yes | Tenant or organisation scope for all tokens derived from this key. |
permissions |
string[] | Yes | Initial list of roles/scopes granted to all tokens from this key. |
1 2 3 4 5 6 7 |
POST /master-keys
Content-Type: application/json
{
"tenantId": "acme-corp",
"permissions": ["read:reports", "write:data"]
}
|
Response 201 Created
1 2 3 4 5 6 |
{
"masterKeyId": "mk_7f2a9b",
"tenantId": "acme-corp",
"permissions": ["read:reports", "write:data"],
"createdAt": 1700000000
}
|
8.3 POST /tokens/issue — Issue Token
Issues a new token from an existing master key. The server generates a nonce, computes the HKDF hash, and returns the encoded token. No per-token record is written to the database.
Request
| Field | Type | Required | Description |
|---|---|---|---|
masterKeyId |
string | Yes | The master key from which to derive this token. |
ttlSeconds |
integer | No | Token lifetime in seconds. Default: 31536000 (1 year). |
1 2 3 4 5 6 7 |
POST /tokens/issue
Content-Type: application/json
{
"masterKeyId": "mk_7f2a9b",
"ttlSeconds": 31536000
}
|
Response 201 Created
8.4 POST /tokens/validate — Validate Token
Validates a raw token and returns the associated permissions. The API gateway calls this on every inbound request before minting a JWT. Since no per-token state exists, this call performs only a master key lookup and HKDF re-derivation.
Request
| Field | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | The raw Base64url-encoded token presented by the client. |
1 2 3 4 5 6 |
POST /tokens/validate
Content-Type: application/json
{
"token": "djErbWtfN2YyYTliOn..."
}
|
Response 200 OK
1 2 3 4 5 6 7 |
{
"valid": true,
"masterKeyId": "mk_7f2a9b",
"tenantId": "acme-corp",
"permissions": ["read:reports", "write:data"],
"expiry": 1798761600
}
|
Response 401 Unauthorized
1 2 3 4 |
{
"valid": false,
"reason": "expired" | "revoked" | "not_found" | "hash_mismatch" | "version_mismatch"
}
|
8.5 POST /tokens/exchange — Exchange for JWT
Accepts a long-lived token in the Authorization header and returns a short-lived, signed JWT for use within the service mesh. This is the primary entry-point for all authenticated API calls.
Request Headers
| Header | Required | Description |
|---|---|---|
Authorization |
Yes | Bearer <base64url-token> |
Response 200 OK
8.6 GET /master-keys/{masterKeyId} — Get Master Key
Returns metadata for a master key record. Never returns secret material.
Response 200 OK
1 2 3 4 5 6 7 8 |
{
"masterKeyId": "mk_7f2a9b",
"tenantId": "acme-corp",
"version": 1,
"permissions": ["read:reports", "write:data"],
"revokedAt": null,
"createdAt": 1700000000
}
|
8.7 PUT /master-keys/{masterKeyId}/permissions — Update Permissions
Replaces the permission set on a master key record. The change takes effect on the next validation for any token derived from this key — no token re-issuance required. For security, automated flows should only permit permission reduction; permission elevation should require explicit operator approval.
Request
| Field | Type | Required | Description |
|---|---|---|---|
permissions |
string[] | Yes | Complete replacement set of permissions for all tokens derived from this master key. |
1 2 3 4 5 6 |
PUT /master-keys/mk_7f2a9b/permissions
Content-Type: application/json
{
"permissions": ["read:reports"]
}
|
Response 200 OK
8.8 DELETE /master-keys/{masterKeyId} — Revoke Master Key
Immediately revokes the master key by setting revokedAt. All subsequent validation calls for any token derived from this masterKeyId will return 401. This is the only revocation granularity available; there is no per-token revocation.
Response 204 No Content
Empty body on success.
Response 404 Not Found
9. Error Codes
| HTTP Status | error / reason |
Meaning |
|---|---|---|
400 |
invalid_token_format |
Token could not be decoded or parsed (wrong number of segments, invalid Base64url). |
401 |
expired |
Token expiry timestamp is in the past. |
401 |
revoked |
Master key record has a non-null revokedAt; all derived tokens are invalidated. |
401 |
not_found |
No master key record exists for the given masterKeyId. |
401 |
hash_mismatch |
HKDF re-derivation did not match the token hash. |
401 |
version_mismatch |
Token version does not match the master key record’s version. |
404 |
master_key_not_found |
Requested masterKeyId does not exist (management endpoints). |
500 |
internal_error |
Unexpected server-side failure. |
10. Security Considerations
10.1 Token Secrecy
Although tokens contain no embedded secrets, possession of a token grants access to all resources the associated master key permits. Tokens must be treated as secrets by clients: stored securely (e.g. OS keychain or encrypted storage), never logged, and transmitted only over TLS.
10.2 Nonce Requirements
The nonce is the sole source of per-token uniqueness. It must be generated using a cryptographically secure pseudorandom number generator (CSPRNG) and must never be reused across tokens issued from the same master key. A minimum of 16 bytes (128 bits) is required. Because the nonce is included in the token and used as the HKDF salt, it need not be stored server-side, but it must be resistant to prediction.
10.3 Revocation Granularity
Revocation operates at the master key level. Revoking a master key invalidates all tokens ever issued from it. Individual token revocation is not possible without maintaining per-token state, which this scheme deliberately avoids. Systems requiring per-token revocation should issue one master key per token. This is a deliberate trade-off: stateless issuance at the cost of granular revocation.
10.4 vs. Signed JWT
This scheme differs from signed JWTs in several important ways. JWTs embed claims and permissions in the token itself, meaning permissions cannot be changed without re-issuing the token. This scheme stores permissions server-side on the master key record, enabling instant permission changes across all derived tokens. The JWT-exchange flow (Section 7) preserves offline validation for internal services: only the API gateway validates the long-lived token; downstream services validate the short-lived JWT offline without a database call.
10.5 Minimum Service Access
Only the token validation microservice requires access to the system secret store and the master key database. All other internal services interact exclusively with the short-lived JWT. This minimises the blast radius of a compromise.
10.6 Secret Rotation — Known Constraint
Because the master key record does not store a reference to the secret used at issuance, rotating the system secret is a breaking operation: all existing tokens will fail HKDF re-derivation and return hash_mismatch on their next validation. Secret rotation therefore requires coordinated re-issuance of all active tokens before the old secret is retired. Operators should treat a secret rotation event as equivalent to revoking all master keys and re-provisioning all clients. If non-breaking rotation is required, a secretId reference should be reintroduced to the MasterKey record.
10.7 Constant-Time Comparison
Hash comparison during validation must use a constant-time byte comparison function to prevent timing-based oracle attacks.
10.8 Tenant Scoping
All master key records are scoped to a tenantId. Validation endpoints must enforce tenant isolation: a token issued for tenant A must never be accepted for tenant B’s resources.
Coppice — HKDF Token Scheme · v1.3 Draft