Security

Defense-in-depth security design of the API token subsystem, covering route boundaries, hashed storage, privilege binding, IP checks, management identity validation, sanitization, and abuse controls.

The API token subsystem protects machine-to-machine credentials with layered controls before, during, and after verification. It rejects malformed keys before the database lookup, stores only SHA-256 token hashes, binds each token to a single privilege, can restrict use to specific IP addresses, and requires full session validation for management actions.

This page explains how the subsystem is designed, what each security control actually does in code, and how the controls work together to protect tokens. For the broader service-level model outside the API token subsystem, see the main Security page.


Route boundary

The API token subsystem exposes two HTTP surfaces: a public verification route and an authenticated management surface. Both inherit service-wide middleware from bootstrapApp, but they intentionally sit behind different route chains.

Service-wide guards

Before either API token router runs, the service disables x-powered-by, applies helmet, sets no-cache headers, validates req.ip, and can enforce HMAC authentication for every request except local GET /health. That means even the public verification route still benefits from anti-framing headers, cache suppression, IP validation, and optional service-to-service authentication.

The service-wide layer protects the subsystem in the following ways:

  • helmet denies framing with X-Frame-Options: DENY and frame-ancestors 'none'.
  • headers sets Cache-Control: no-cache, private, max-age=0, Pragma: no-cache, and Expires: 0 on responses.
  • validateIp rejects requests whose resolved req.ip is missing or invalid with 403 Forbidden.
  • hmacAuth can turn the API token routes into internal-only routes when service.Hmac is enabled.

Public verification route

GET /api/public/verify is mounted through apiVerificationRoute(). The service mounts it before cookieParser(), before botDetectorApi, and before the authentication and management route groups, so it keeps a smaller attack surface and does not depend on session cookies or JSON parsing.

The controller only accepts the raw API key through the x-api-key header and the required privilege through the privilege query parameter. It relies on the token verifier and API-token limiter group instead of the full session pipeline.

Authenticated management routes

/api/manage/:action is the private surface. The POST route requires requireAccessToken, requireRefreshToken, getFingerPrint, checkForActiveMfa, protectRoute, contentType('application/json'), and express.json({ limit: '1kb' }) before apiTokensController runs. The GET route for listing uses the same chain except for the JSON-only and body-parser checks.

That means a management action must pass bearer-token presence, refresh-session presence, canary-bound session checks, anomaly detection, MFA gating, JWT verification, and the access-token blacklist before any API token management branch executes.

The direct library helpers do not apply this route chain. If you call helpers like updatePrivileges, updateRestriction, or getAllValidTokensList directly, you must provide the surrounding authentication and trust boundary yourself.

Token material and storage

The subsystem separates token secrecy from token management. The raw token is a secret credential. The public identifier is only a management reference.

Raw token structure and checksum

Each created API token uses the format prefix_random_checksum.

  • prefix comes from the caller or defaults to api.
  • random is 64 bytes from crypto.randomBytes(64) encoded in hex.
  • checksum is the first 8 hex characters of sha256(randomPart).

createApiKey rejects any prefix containing _, because _ is the reserved delimiter that separates the three token segments.

The subsystem also generates a separate public_identifier. It uses a second 64-byte random value and the same short checksum pattern. The browser or BFF uses this public identifier for dashboard actions so it never needs the raw API key or the stored token hash.

Hashed storage

Before insertion, createApiKey passes the raw token through toDigestHex and stores only the SHA-256 digest in api_tokens.api_token. The database never stores the raw token, and the raw token is only returned once in the creation or rotation success response.

The public_identifier is intentionally not hashed. It is not treated as a secret. Its job is to identify a token safely during authenticated management flows without revealing the actual credential.

Privilege scoping and token inventory

Every token row is bound to one privilege type: demo, restricted, protected, full, or custom. verifyApiKey queries by both api_token = ? and privilege_type = ?, so the same key cannot be reused at another privilege level.

createApiKey also enforces apiTokens.limitTokensPerUser before generating a new credential. If the user already has too many valid tokens, creation fails before any new key is minted.

Verification and lifecycle protections

Verification uses a layered sequence. The earlier layers reject malformed input cheaply. The later layers enforce ownership, host restrictions, and lifecycle state against the database.

Fast reject before the database lookup

When the caller provides a raw key, verifyApiKey splits it into prefix, randomPart, and checksum. If any piece is missing, verification returns Invalid key immediately.

Then it recomputes the checksum from the random part and compares it with crypto.timingSafeEqual. Counterfeit, truncated, or corrupted raw keys are rejected before the database query runs.

Hash normalization and strict lookup

After structure validation, verifyApiKey hashes the input if needed and validates that the result is a well-formed SHA-256 hex digest with ensureSha256Hex. The database lookup only accepts rows where valid = 1 and the stored privilege exactly matches providedPrivilege.

This gives the subsystem two security layers. The first rejects malformed credentials without spending a query. The second ensures a leaked digest cannot be reused with a different privilege or after revocation.

IP restriction enforcement

If the token row contains restricted_to_ip_address and the caller does not set byPassIpCheck, verification parses the stored JSON list and requires the provided IP address to exist in that whitelist. If the request does not supply an IP or the IP is not in the list, verification rolls back and returns Invalid Host.

That means IP restrictions are enforced at verification time, not only stored as metadata. A token can exist and still be unusable from the wrong network.

Expiration invalidation

If expires_at exists and its time is in the past, verifyApiKey immediately updates the row to valid = 0, commits that invalidation, and returns Token expired. The token does not stay temporarily valid after the first expired use.

This turns expiration into both a time check and a state change. After the first expired attempt, later verification no longer sees the token as active.

Usage tracking

On a successful external verification, verifyApiKey increments usage_count and updates last_used = UTC_TIMESTAMP() inside the same transaction. The query uses FOR UPDATE unless skipCountUpdates is enabled, which prevents concurrent requests from racing the usage counter.

Internal flows can disable this behavior when they need to inspect or revoke a token without treating that action as token consumption. That is why metadata reads and internal revoke checks do not inflate usage_count.

Revocation and rotation

Revocation sets valid = 0 instead of deleting the row. That preserves metadata and makes revoked keys fail the main verification lookup automatically.

Rotation first revokes the current token and only then creates the replacement key. When management routes call rotation through privateActionManager, the replacement inherits the current privilege, name, prefix, remaining TTL, and IP restrictions, which prevents a rotation flow from silently widening access.

Management

Management actions are designed so the client can act on a token without ever handling the raw secret. The manager resolves the real stored token hash on the server after it validates a public identity record.

Public identifier proxy

privateActionManager takes userId, tokenId, name, and publicIdentifier, validates the public identifier checksum, and then queries the database for an exact row match.

That query requires id, user_id, name, public_identifier, and valid = 1 to line up. If any of those values do not match, the manager returns Bad Request and does not disclose whether a different token exists.

Scoped action dispatch

Once the manager resolves the row, it uses the stored hashed api_token and the stored privilege to dispatch the requested action. That means revoke, rotate, metadata, IP updates, and privilege updates all execute against the database record the user already owns, not against a caller-supplied raw key.

This is the core safety model for dashboard actions. The browser handles a non-secret public identifier, while the server resolves the real token hash internally.

Listing without secret exposure

getAllValidTokensList deliberately returns metadata plus public_identifier, but never the raw API key or the stored hash. Authenticated clients can render a management UI from that list and still avoid handling the secret itself.

Token listing does not use privateActionManager, but the route still requires the full authenticated management middleware chain before it can return the list.

Input validation and sanitization

The subsystem validates both route parameters and request bodies before helper logic runs. It also treats hostile HTML as an attack signal instead of a normal bad-request case.

Zod schemas and safe strings

The public API surface is constrained by newApiTokenSchema, standardSchema, ipRestrictionUpdate, privilegeUpdate, privilegeQ, and reqParams. These schemas validate action names, token names, prefixes, privilege values, token ids, and public identifiers before the controller calls the token helpers.

Every string field built with makeSafeString passes through the HTML sanitizer before it is accepted. That includes token names, prefixes, and public identifiers.

XSS detection and bans

If sanitized input still contains HTML or event-handler style payloads, makeSafeString raises a custom Zod issue and validateSchema converts it into an XSS handling path. That path calls handleXSS, logs the incident, and returns { banned: true } with 403 Forbidden.

This is why the API token routes do not treat hostile HTML like a normal validation miss. They treat it as an attack signal.

JSON and body-size enforcement

Management POST routes require Content-Type: application/json. If the content type is wrong, contentType() returns 403 before apiTokensController runs.

The POST parser also caps bodies at 1kb and rejects empty JSON bodies during the parser verify hook. The GET listing route bypasses the body parser entirely because it does not need a body.

Rate limiting and failure shaping

The subsystem uses different rate-limit strategies for the public verification path and the authenticated management routes. It also intentionally flattens some security failures into shared public reasons to reduce token-state disclosure.

Abuse controls

The public verify route uses consumptionRateLimiter for failed attempts and can optionally use generalUnionLimiter when apiTokens.rateLimitOnSuccessfulRequest is enabled. The management routes put generalUnionLimiter in front of every action and add a dedicated limiter for creation, revocation, metadata, rotation, IP updates, or privilege updates when needed.

See Rate Limiting for the full limiter map, default behavior, reset rules, and permanent-block escalation.

Security-oriented error design

The subsystem intentionally collapses several failure states into the same public reason so callers cannot easily enumerate token state.

  • Invalid key covers malformed raw keys, checksum failures, missing rows, revoked rows, and privilege mismatches in verifyApiKey.
  • Invalid Host only appears when a real token exists but the caller IP does not satisfy the stored whitelist.
  • Token expired appears once the subsystem has marked the expired row invalid.
  • Invalid identity is reserved for bad publicIdentifier checksum validation in privateActionManager.
  • Bad Request in manager-backed actions means the requested token metadata did not match a currently valid row or the action itself was unsupported.
  • 429 Too Many Requests always comes from the limiter layer, not from token business logic.
  • 202 with mfa: true can appear before management actions when the session already has an active MFA challenge or anomaly detection requires a new one.

The direct revoke helper goes one step further and returns success even when the token is already invalid or missing. That behavior reduces token-state disclosure for direct server-side callers, while the route stays stricter because it only resolves currently valid rows through privateActionManager.

Known limitations

The subsystem is deliberately opinionated, but some protections are only as strong as the way you call the API.

  • Direct library helpers are trusted server-side primitives. They do not apply the route-level authentication, anomaly checks, MFA gating, or JSON guards.
  • getUserApiKeysMetaData intentionally bypasses IP enforcement and usage counter updates so metadata reads do not consume the token.
  • Internal revoke checks also bypass IP enforcement and usage counter updates. That is useful for administration, but it means the helper is not a full substitute for the route chain.
  • public_identifier is not a secret. It is safe to expose to an authenticated dashboard, but it is still a management capability and must stay behind your trusted management surface.
  • The verification route is public by design unless you enable service.Hmac. If you want token verification to stay internal to your BFF, turn HMAC on.
Use the authenticated routes when you want the full subsystem security model. Use the direct helpers only in trusted server-side code where you intentionally control the surrounding authentication and transport boundary.
Logo