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

  1. Overview
  2. Token Format
  3. Key Derivation (HKDF)
  4. Server-Side Data Model
  5. Token Lifecycle
  6. Audit Trail
  7. JWT Exchange Flow
  8. API Definitions
  9. Error Codes
  10. 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.

  1. Look up the MasterKey record for the requested masterKeyId (must exist and not be revoked).
  2. Generate a cryptographically random nonce (16 bytes, from a CSPRNG).
  3. Set expiry to now + TTL (default: 1 year). TTL may be overridden in the request.
  4. Run HKDF with nonce as salt and version || masterKeyId || expiry as info.
  5. Assemble and Base64url-encode the token: version:masterKeyId:nonce:expiry:hash.
  6. Return the token to the caller. No database write occurs.

5.2 Validation

  1. Decode the Base64url token; split on : to extract version, masterKeyId, nonce, expiry, hash.
  2. Reject immediately if expiry < now (client-side pre-check is also possible using the expiry field).
  3. Look up MasterKey by masterKeyId; return 401 if not found or revokedAt is set.
  4. Verify the version field in the token matches the version on the master key record (prevent cross-version hash reuse).
  5. Load the system secret; re-run HKDF using nonce as salt and masterKeyId with record parameters.
  6. Compare derived hash to token hash using a constant-time comparison; return 401 on mismatch.
  7. 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:

  1. Client sends a request with Authorization: Bearer <token>.
  2. API Gateway calls POST /tokens/validate.
  3. Token Service checks token expiry (no DB needed).
  4. Token Service looks up MasterKey, checks revokedAt.
  5. Token Service re-derives the HKDF hash and compares (constant-time).
  6. Token Service returns { masterKeyId, tenantId, permissions[] }.
  7. API Gateway mints a short-lived JWT (exp: ~1 hour, signed with gateway private key).
  8. API Gateway forwards the request with the JWT to downstream services.
  9. 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

1
2
3
4
5
{
  "token":       "djErbWtfN2YyYTliOn...",
  "masterKeyId": "mk_7f2a9b",
  "expiry":      1798761600
}

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>
1
2
POST /tokens/exchange
Authorization: Bearer djErbWtfN2YyYTliOn...

Response 200 OK

1
2
3
4
{
  "jwt":       "eyJhbGciOiJSUzI1NiJ9...",
  "expiresIn": 3600
}

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

1
2
3
4
5
{
  "masterKeyId": "mk_7f2a9b",
  "permissions": ["read:reports"],
  "updatedAt":   1700005000
}

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

1
2
3
{
  "error": "master_key_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