Security
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:
helmetdenies framing withX-Frame-Options: DENYandframe-ancestors 'none'.headerssetsCache-Control: no-cache, private, max-age=0,Pragma: no-cache, andExpires: 0on responses.validateIprejects requests whose resolvedreq.ipis missing or invalid with403 Forbidden.hmacAuthcan turn the API token routes into internal-only routes whenservice.Hmacis 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.
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.
prefixcomes from the caller or defaults toapi.randomis 64 bytes fromcrypto.randomBytes(64)encoded in hex.checksumis the first 8 hex characters ofsha256(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 keycovers malformed raw keys, checksum failures, missing rows, revoked rows, and privilege mismatches inverifyApiKey.Invalid Hostonly appears when a real token exists but the caller IP does not satisfy the stored whitelist.Token expiredappears once the subsystem has marked the expired row invalid.Invalid identityis reserved for badpublicIdentifierchecksum validation inprivateActionManager.Bad Requestin manager-backed actions means the requested token metadata did not match a currently valid row or the action itself was unsupported.429 Too Many Requestsalways comes from the limiter layer, not from token business logic.202withmfa: truecan 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.
getUserApiKeysMetaDataintentionally 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_identifieris 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.