API keys
All requests require an API key, exceptGET /health and GET /health/identity, which are unauthenticated by design — qwed init calls the identity endpoint before it has a key to send.
Header authentication
/verify/*, admin, and metrics routes, with 401 Unauthorized or 403 Forbidden. Keys issued by rotation expire after 90 days. Rotate a key before it expires.
SDK authentication
Environment variables
Set your API key as an environment variable:API key format
QWED API keys use a fixed prefix followed by a fixed-length body. The prefix tells you the key type. The body shape tells you the key version.Production key versions
QWED issues new keys in the v2 format. Existing v1 keys keep working and are never force-rotated.
qwed_test_ keys use the same body versions; a test key can be a structurally valid v2 (checksummed) key, so treat the prefix—not the shape—as the production/non-production signal.
The v2 checksum is CRC32 (IEEE, as in zlib) over the ASCII encoding of the first 30 body characters. QWED Base62-encodes the result with the alphabet 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz and zero-pads it to a width of 6. You can validate a v2 key offline: split the body into the first 30 and last 6 characters, recompute the checksum, and accept only on an exact match. This reduces random false-positive matches to about 2^-32 without a database call.
The checksum authenticates the key’s shape, not its authorization. A key with a valid checksum still has to pass the server-side HMAC lookup before QWED accepts it.
Scanning for leaked keys
If you build a secret scanner, leak detector, or pre-commit hook, use these rules:- Match only
qwed_live_. Excludeqwed_test_from high-confidence production-secret matching. Registered scanner patterns match only keys with theqwed_live_prefix, so scanners do not report test keys to QWED. The revocation sink itself applies no prefix filter. - Anchor with lookarounds, not
\b. A v1 body can end in-, which no word boundary borders. Use charset-complement lookarounds so a match ends exactly where the body alphabet stops. - Post-process v2 matches. Recompute the checksum and discard matches that fail. For v1, enforce the exact shape only.
- With the RE2 patterns below, use the capture group. Send group 1 as the token and resume scanning after it; the whole match includes surrounding delimiter characters.
(?:^|[^...]) and (?:[^...]|$) delimiters match characters that are not part of the key, so always read the match’s group 1 as the token, never the whole match. Submitting the whole match sends one or two extra characters around the key, which breaks checksum validation and the revocation lookup. Group 1 also lets a global scan continue from the end of the key rather than the end of the whole match, so back-to-back keys separated by a single delimiter are not skipped:
QWED_LIVE_API_KEY (v2) and QWED_LIVE_API_KEY_V1 (v1). QWED receives signed leak reports for these patterns at POST /webhooks/secret-scanning.
Masking keys
When you display a key in logs, dashboards, documentation, or examples, show the prefix, asterisks, and only the last 4 characters. Never show a full key body.Machine-readable spec and test vectors
Scanner and SDK authors can consume the canonical format directly:spec/api-key-format.json: prefixes, full and anchored patterns, checksum parameters, and excluded prefixes.spec/api-key-test-vectors.json: synthetic, non-functional valid and invalid keys with expected classifications (v2,v1,invalid, orexcludedfor test-prefix keys that are shape-valid but must never alert). None of these values authenticate.docs/API_KEY_FORMAT.md: the human-readable specification.
API key storage and migration (v7.2)
As of v7.2, QWED stores API keys as HMAC-SHA256 lookup digests keyed by a dedicated server-side secret (QWED_API_KEY_LOOKUP_SECRET). Earlier versions used PBKDF2, which added ~67ms of CPU cost to every request on the unauthenticated lookup path. Because API keys are high-entropy random tokens, the key-derivation cost bought no additional brute-force resistance, so it was removed.
Self-hosted configuration
Self-hosted deployments must setQWED_API_KEY_LOOKUP_SECRET before starting the server:
- The server fails closed at startup if the variable is missing.
- The value must differ from
QWED_JWT_SECRET_KEY. Equal values are rejected at startup, because reusing the JWT secret would break every API-key lookup on the next JWT-secret rotation. - Set the secret before issuing any v7.2 keys. Digests are derived from the secret active at issue time, so changing it later requires a one-time re-issue of all keys.
Security best practices
- Never commit API keys to version control
- Use environment variables in production
- Rotate keys regularly using the dashboard
- Set IP allowlists for production keys
- Use test keys for development
Agent authentication
Agent tokens are separate from API keys and are used for agent-specific endpoints.X-Agent-Token header:
Agent token storage
QWED stores each agent token as an HMAC-SHA256 digest keyed withQWED_API_KEY_LOOKUP_SECRET, not as plain text. The token is shown only once, in the response to registration or re-issue. Store it when you receive it.
Tokens issued before this change keep working. At startup, after its fail-closed checks, the server hashes any agent token still stored in plain text, in place. You don’t need to re-register agents.
To replace a token, for example one whose plain text might remain in a database backup, call POST /agents/{agent_id}/token/reissue. QWED returns a new token and the old one stops working.
Scopes
API keys can have restricted scopes:Auth endpoints
The following endpoints manage user accounts and API keys via JWT-based authentication. Anonymous/auth/* routes are rate limited per client IP (default 10 requests per minute). Exceeding the limit returns 429 Too Many Requests with a Retry-After header. See Per-IP limits on authentication endpoints.
POST /auth/signup
Create a new user and organization. Returns a JWT token for immediate use. Request:400 Bad Request and "Password must be at most 72 bytes". Characters outside ASCII take more than one byte each, so a password with fewer than 72 characters can still be too long. QWED checks the length before it looks up the email, so the response is the same whether or not the email is registered.
Response:
org_name is the organization’s display name, or null if QWED can’t read it.
POST /auth/signin
Sign in an existing user. Request:/auth/signup.
A password over 72 bytes in UTF-8 can’t match any account. Sign-in returns 401 Unauthorized with "Invalid email or password" for it, whether or not the email is registered.
Sign-in returns 403 Forbidden for a deactivated user ("Account is deactivated") and for a user whose organization is inactive or missing ("Organization is not active").
GET /auth/me
Get the current authenticated user’s information. Requires aBearer token in the Authorization header.
user object as sign-up and sign-in, including org_name. QWED applies the user and organization checks in Roles on API-key and audit routes on every call. An inactive user or organization, or an unknown role, returns 403 Forbidden even while the token is still valid.
GET /auth/users
List the users of your organization, oldest first. Use this to show who is in the organization, or to name the owner of a key or the user behind a security event. Owners and admins can call this endpoint with a bearer token or with an API key in theX-API-Key header. When you send both, QWED uses the API key. Members and viewers get 403 Forbidden.
is_active set to false. The response contains no password or permission data.
Roles on API-key and audit routes
The/auth/api-keys, /auth/users, and /audit routes authorize by the signed-in user’s role. Roles rank from lowest to highest: viewer, member, admin, owner.
Before QWED checks the role, it checks the user and organization. QWED returns
403 Forbidden when:
- The user is inactive (
"User is not active"). - The user’s organization is inactive or missing (
"Organization is not active"). - The user’s role isn’t allowed on the route, including any role not listed above (
"Insufficient permissions for this operation").
POST /auth/api-keys
Generate a new API key for the current user’s organization. Requires JWT authentication and themember, admin, or owner role. A viewer gets 403 Forbidden.
Request:
GET /auth/api-keys
List active API keys. Admins and owners see every active key in their organization. Members and viewers see only their own keys. Each key includesid, name, key_preview, created_at, last_used_at, and is_revoked. QWED sets last_used_at after a key passes every authentication and authorization check, at most once a minute per key. A key that has never been used, or was refused by a check, keeps its previous value (null if never used). If QWED can’t write last_used_at, the request still succeeds.
Each key also includes:
expires_at: when the key expires, ornullif it doesn’t expire.rotation_required:truewhen the key is flagged for rotation.owner: the user the key belongs to, as{"id": ..., "email": ...}. It isnullfor a key with no user, or whose user isn’t in the key’s organization, so QWED never shows another organization’s user.
created_at, last_used_at, and expires_at always carry their UTC offset.
DELETE /auth/api-keys/
Revoke an API key. Performs a soft delete.- Any role can revoke its own key.
- An
admincan revoke another user’s key unless that user holds a higher role. Revoking an owner’s key returns403 Forbidden. - An
ownercan revoke any key in the organization. - A
memberorviewergets404 Not Foundfor a key they don’t own, so the response doesn’t confirm that the key exists. - A key ID that isn’t a positive integer, or that belongs to another organization, returns
404 Not Found.
Audit endpoints
These endpoints require JWT authentication (Bearer token) and return data scoped to the authenticated user’s organization. Every role can read the log. Exporting it and seeding samples needadmin or owner. See Roles on API-key and audit routes.
GET /audit/logs
Get audit logs for the current organization, newest first.
Each entry includes
verdict, the verdict stored in the row’s result, admission, the admission decision its route made, and entry_hash, the row’s hash in the signed audit chain. Each entry also records who wrote it (api_key_id) and with which server release (verifier_version).
The entry hash covers
admission, api_key_id, verifier_version, and latency_ms. Changing or clearing any of them on a stored row fails integrity verification.
Paging through the log
QWED orders rows by timestamp, then by ID, newest first, so the order is stable when two rows share a timestamp. To read the next page, passnext_before back as before. Stop when next_before is null. A full last page is followed by an empty page with next_before: null.
before ID that is not a log in your organization, or is outside the SQL BIGINT range (1 to 263−1), returns 404 Not Found.
GET /audit/logs/
Get detailed information for a single audit log entry. Returns404 for a log ID outside your organization or outside the SQL BIGINT range.
The response has the same verdict, status, admission, api_key_id, and verifier_version fields as the list, plus the row’s full chain entry as stored:
These fields identify the chain entry. To check that it is intact, call
GET /admin/compliance/verify/{log_id}.
GET /audit/export
Export your organization’s audit log as a CSV file (text/csv). Requires the admin or owner role.
Pass
start and end as ISO 8601 times. A time without a zone is read as UTC, and other offsets are converted. Without either, QWED doesn’t bound that side of the period, so with neither you get the newest rows. start after end returns 400 Bad Request with "start must not be after end".
GET /admin/compliance/export/csv: ID, Timestamp, User ID, API Key ID, Verifier Version, Domain, Query, Verdict, Admission, Is Verified (legacy), Result, Latency (ms), Entry Hash, HMAC Signature, and Chain Status. Verdict, Admission, and Latency (ms) are the stored values, as verdict, admission, and latency_ms on GET /audit/logs. Domain is the same value as engine_used. A value QWED didn’t record is an empty cell, and no cell is truncated. QWED prefixes an apostrophe to any cell that starts with =, +, -, @, a tab, a carriage return, or a line feed, so spreadsheet software doesn’t run it as a formula.
Rows are newest first, ordered by timestamp and then by ID. The export holds at most the newest 1,000 rows. When more rows matched, the response sets X-Export-Truncated: true, otherwise false; CORS exposes it to browser clients on allowed origins.
Before this change,
GET /audit/export wrote is_verified as verified or blocked under a Status column, with no admission or caller. Update any client that reads the old columns by name or position.