Short answer: treat an API key as a credential bound to one tenant identity, an explicit permission set, and a finite validity window. Store only a verifier, check revocation on every request, and write the resolved tenant and key identifiers into immutable usage events. That is the smallest design that keeps a marketplace request attributable when invoices depend on it.
The deciding constraint is billing accuracy. A key that merely proves "someone knows this secret" is too vague. If two sellers share it, or if authorization is reconstructed later from mutable account data, the invoice trail cannot reliably answer who produced a charge.
For a one-person SaaS, this is also a revenue-per-hour decision. I want a small credential boundary that I can test and ship this week, while leaving secret storage replaceable later. The differentiated work is the marketplace's usage model, not custom cryptography or a home-grown vault.
How do API key identity, scope, and lifetime work?
A useful record separates four concerns that often get packed into one string:
| Concern | Stored meaning | Request-time question |
|---|---|---|
| Identity | Tenant ID and stable key ID | Which tenant should receive this usage? |
| Scope | Allowed actions, such as orders:read
|
May this credential perform this operation? |
| Lifetime |
createdAt, expiresAt, and optional revokedAt
|
Is it valid now? |
| Verification | A one-way digest of random secret material | Does the presented secret match? |
Identity is attribution, not a display label. A seller changing its shop name must not move old usage to another billing account. Scope is an allowlist evaluated before the operation. Lifetime covers issuance, overlap during rotation, expiry, and revocation.
Keep the key ID non-secret and the secret material unpredictable. A presented value can have a shape such as mk_<keyId>_<secret>, allowing an indexed lookup by key ID before verification. The prefix is a parser aid, not a security boundary. Show the secret once. Keep its digest in the database.
Those boundaries are the point.
The smallest working credential boundary
This example leaves persistence behind an interface. In production, the store must make creation and revocation durable, and callers must use UTC timestamps. Node's standard cryptography module supplies random bytes, SHA-256, and constant-time comparison; inventing cryptography here adds risk without improving the marketplace.
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
type Scope = "orders:read" | "orders:write" | "usage:submit";
type KeyRecord = {
keyId: string;
tenantId: string;
secretDigest: Buffer;
scopes: readonly Scope[];
createdAt: Date;
expiresAt: Date;
revokedAt: Date | null;
};
type CredentialStore = {
insert(record: KeyRecord): Promise<void>;
findById(keyId: string): Promise<KeyRecord | null>;
};
const digest = (secret: string): Buffer =>
createHash("sha256").update(secret, "utf8").digest();
export async function issueKey(
store: CredentialStore,
tenantId: string,
scopes: readonly Scope[],
expiresAt: Date,
now = new Date(),
): Promise<{ apiKey: string; keyId: string }> {
if (expiresAt.getTime() <= now.getTime()) {
throw new Error("expiresAt must be in the future");
}
const keyId = randomBytes(12).toString("hex");
const secret = randomBytes(32).toString("base64url");
await store.insert({
keyId,
tenantId,
secretDigest: digest(secret),
scopes: [...new Set(scopes)],
createdAt: now,
expiresAt,
revokedAt: null,
});
return { apiKey: `mk_${keyId}_${secret}`, keyId };
}
export async function authenticate(
store: CredentialStore,
presented: string,
requiredScope: Scope,
now = new Date(),
): Promise<{ tenantId: string; keyId: string }> {
const match = /^mk_([0-9a-f]{24})_([A-Za-z0-9_-]{43})$/.exec(presented);
if (!match) throw new Error("invalid credential");
const [, keyId, secret] = match;
const record = await store.findById(keyId);
if (!record) throw new Error("invalid credential");
const matches = timingSafeEqual(digest(secret), record.secretDigest);
const active = record.revokedAt === null && now < record.expiresAt;
const allowed = record.scopes.includes(requiredScope);
if (!matches || !active || !allowed) throw new Error("invalid credential");
return { tenantId: record.tenantId, keyId: record.keyId };
}
The public error stays deliberately dull. Internally, log a reason code such as malformed, unknown, mismatch, expired, revoked, or insufficient scope, but never log the presented credential. OWASP's secrets-management guidance calls for least privilege, rotation where possible, expiration, revocation, and logging that avoids secret exposure.
One detail matters for billing: application code gets tenant identity from authenticate; it does not accept a tenant ID from the request body as billing authority. A seller may include an order owner for business logic, but that field cannot override the credential's tenant.
Make the usage event carry the answer
Do not ask a mutable key table to reconstruct last month's invoice. Emit an append-only usage event after authentication and authorization resolve the actor. Give the event a unique ID so retries can be deduplicated.
type UsageEvent = {
eventId: string;
tenantId: string;
credentialKeyId: string;
operation: "order.read" | "order.write" | "usage.submit";
occurredAt: string;
units: number;
};
export function usageFromRequest(
actor: { tenantId: string; keyId: string },
input: Omit<UsageEvent, "tenantId" | "credentialKeyId">,
): UsageEvent {
if (!Number.isInteger(input.units) || input.units <= 0) {
throw new Error("units must be a positive integer");
}
return { ...input, tenantId: actor.tenantId, credentialKeyId: actor.keyId };
}
Now revoking a key does not erase attribution already attached to accepted usage. Keep the historical key ID in the event, never the secret or its digest. The tenant ID drives the invoice; the key ID explains which credential produced each item.
This catches a common modeling error. If a seller creates a replacement key, both keys may overlap briefly, yet both resolve to the same tenant. Aggregation remains stable while operators retain enough detail to investigate duplicate clients or a forgotten deployment.
How should rotation and revocation behave?
Rotation is issuance plus controlled overlap, followed by revocation. Do not mutate the old secret in place. Issue a record with a new key ID, deploy it, observe successful use, then revoke the old record. A finite expiry still matters because it bounds a forgotten credential when planned cleanup never happens.
Revocation needs a crisp clock rule: accept only while revokedAt is null and now is earlier than expiresAt. If credentials are cached, cache policy determines revocation delay. For a small service where immediate revocation matters, checking the authoritative store on each authenticated request is the clearest start. Add caching after measured load justifies weaker freshness or an explicit invalidation channel.
This design has limits. A database lookup on every request is not suitable for a globally distributed, high-throughput API when the credential store is far from callers; choose a bounded metadata cache or regional verifier there, and accept that the bound also becomes the worst-case revocation delay unless invalidation is pushed. A single opaque API key is also the wrong choice when delegated end-user consent, browser sign-in, or third-party access on a user's behalf is required; choose a standards-based delegated authorization flow instead. Finally, a SHA-256 digest protects a randomly generated 32-byte secret, but it does not rescue a human-chosen token with little entropy. The issuer, not the user, must generate this material. Each alternative buys a capability while adding protocol, operational, or freshness cost. That is the trade-off.
Test the boundaries, not only the happy path:
- A key for tenant A cannot attribute usage to tenant B, even if the body says B.
- A read-only key cannot write an order.
- A key works immediately before expiry and fails at the expiry instant.
- A revoked key fails on the next authoritative check.
- Old and new keys coexist during rotation with distinct key IDs and one tenant ID.
- Retrying one
eventIddoes not create two billable events. - Logs and usage events contain no raw key or secret digest.
These tests earn their keep. A glossy admin screen does not. Ship the invariant first.
What I would change at scale
The data model survives growth, but storage and operations change. I would move secret custody and rotation workflows to a dedicated secrets system because key protection is undifferentiated infrastructure. The application should retain a generic contract: issue, verify, list metadata, revoke, and audit.
At higher request volume, digest verification can remain local while revocation metadata is cached for a bounded interval. That exchanges immediate revocation for throughput, so the interval must be an explicit security decision rather than a hidden library default. High-risk operations may bypass the cache. Batch usage ingestion also needs idempotent writes keyed by eventId; a queue can absorb bursts, but it cannot repair ambiguous identity upstream.
I would separate operational audit records from billable usage. Issuance and revocation belong in an audit trail with actor, time, and key ID. Successful business operations belong in the usage ledger. Authentication failures belong in security telemetry with rate limits and reason codes. Mixing all three makes retention, access control, and incident review harder.
More components carry a cost. For a solo SaaS, I would wait until traffic, compliance needs, or customer operations make it real. Start with one durable store and transactional usage deduplication. Preserve the boundaries now so outsourcing storage later does not rewrite billing semantics.
The decision rule is compact: every accepted request must resolve one stable tenant, one immutable credential ID, one permitted action, and one validity decision at a recorded time. If the system cannot produce those four answers without trusting caller-supplied billing identity, the key model is incomplete.
Top comments (0)