Authenticate requests with an API key
Every OpenWA REST route is protected by an API key sent in the X-API-Key header. This guide shows you how to send that key, how a key's role and scope decide what it can do, and how to mint, inspect, and revoke keys through the API.
- A running OpenWA instance — see Installation.
- The seeded admin key from first boot (covered in The first admin key).
curl, or the JS SDK (@rmyndharis/openwa).
Send the key on every request
Authenticate by adding the X-API-Key header to each request. The base URL in these examples is http://localhost:2785/api (the /api global prefix on the default port 2785); in production, OpenWA sits behind your own domain and TLS.
curl http://localhost:2785/api/sessions \
-H "X-API-Key: YOUR_API_KEY"
A successful call returns the resource:
[
{ "id": "main", "status": "connected", "createdAt": "2026-06-25T09:30:00.000Z" }
]
Replace YOUR_API_KEY with a real key — a full key looks like owa_k1_ followed by 64 hex characters. Add Content-Type: application/json only when you send a request body. The same key authenticates the JS SDK:
import { OpenWAClient } from "@rmyndharis/openwa";
const client = new OpenWAClient({
baseUrl: "http://localhost:2785",
apiKey: "YOUR_API_KEY",
});
const sessions = await client.sessions.list();
OpenWA accepts the key as Authorization: Bearer YOUR_API_KEY in addition to X-API-Key. Use X-API-Key as the canonical form so every example on this site stays consistent.
?apiKey=A key in the URL leaks into proxy and access logs, so the Socket.IO handshake no longer accepts the ?apiKey= query string. Connect with the key in the auth.apiKey field (recommended) or send the X-API-Key header instead:
import { io } from "socket.io-client";
// Recommended: key in the auth field
const socket = io("http://localhost:2785", { auth: { apiKey: "YOUR_API_KEY" } });
// Alternative: key in a header
const socket = io("http://localhost:2785", {
extraHeaders: { "X-API-Key": "YOUR_API_KEY" },
});
Rejected WebSocket authentication attempts are audited with the same api_key_auth_failed event the REST guard emits, so credential probing over the WebSocket surface leaves a forensic trail.
The gateway bounds the WebSocket surface with three independent limits: a per-IP handshake window (10 per 60 s by default) charged before authentication and refunded once the handshake proves authentic — it bounds failed handshakes without letting clients behind one NAT shut each other out; a per-key cap of 16 simultaneous sockets; and a per-key frame token bucket (60 frames/s, 120-frame burst). A socket that exceeds its limit is closed and writes a rate_limit_exceeded audit row. Tune the limits with WS_RATE_LIMIT_HANDSHAKE_MAX, WS_RATE_LIMIT_HANDSHAKE_WINDOW_MS, WS_MAX_SOCKETS_PER_KEY, WS_RATE_LIMIT_FRAME_PER_SECOND, and WS_RATE_LIMIT_FRAME_BURST.
The first admin key
On its first boot, when no keys exist yet, OpenWA seeds one admin key. It is printed to the startup log and written to data/.api-key (mode 0600; /app/data/.api-key in Docker). On later restarts the log shows only a masked fingerprint — the full key stays in that file and in the dashboard.
The file tracks the key, not the other way around: OpenWA validates it against the stored key's hash at boot and on the revoke and delete paths, and removes it once its key no longer validates. The one exception is a changed API_KEY_PEPPER — a key row still carries the file's prefix but the hash no longer matches. The file then survives and a startup WARN names the repair: restore the original pepper, or rotate the key.
Use this admin key to mint scoped, lower-privilege keys for your integrations, then keep the admin key for management only. If you set API_MASTER_KEY in your environment, OpenWA seeds that value instead of a random one — see Configuration.
Roles
Every key carries exactly one role. Roles are hierarchical — a higher role satisfies any route that requires a lower one, so an admin key passes an operator-guarded route.
| Role | Rank | Can do |
|---|---|---|
viewer | 1 | Read-only routes — list sessions, read messages, view contacts and groups. |
operator | 2 | Everything viewer can, plus write and action routes — send messages, manage groups, manage webhooks. |
admin | 3 | Everything, plus admin-only routes — API-key management, runtime settings (GET /api/settings; the PUT was removed in v0.19.0), the audit log (GET /api/audit), the webhook delivery-failure log (GET /api/webhooks/delivery-failures), the instance-wide stats aggregates (GET /api/stats/overview and /api/stats/messages), plugin management (/api/plugins), integration instances and redrive (/api/integration/*), and the infrastructure routes (/api/infra/*, apart from the public /api/infra/health probe). |
When role is omitted at creation, the key defaults to operator.
A key whose role is below what a route requires gets 403 Forbidden. This is distinct from 401 Unauthorized, which means the key itself was missing, invalid, revoked, expired, or out of scope. See Errors.
Scope a key to sessions and IPs
Beyond its role, a key can be narrowed so it works only for certain sessions or from certain source IPs. Both fields are optional; an empty or absent list means "no restriction" for that dimension.
| Field | Type | Effect |
|---|---|---|
allowedSessions | string[] | Session ids the key may touch. A scoped key acts only on these sessions; list endpoints return only their data. |
allowedIps | string[] | Allowlist of exact IPs and CIDR ranges (for example 203.0.113.50, 10.0.0.0/8). |
Scope is enforced during key validation, before the role check. A request from a session or IP outside the key's allowlist is rejected with 401 Unauthorized — even if the role would otherwise permit it. A non-empty allowedIps fails closed: if the client IP can't be determined, the request is also rejected.
The session allowlist also fences integration instance management, whose scope travels in the request body rather than the route. Instance create, patch, and redrive intersect the requested and persisted session scope with your key's allowlist: an instance outside it answers 404 Not Found — indistinguishable from a missing one, so out-of-scope instances cannot be probed — and a scoped key cannot create an all-sessions instance (an omitted or '*' sessionScope).
The client IP is taken from the socket unless the request arrives from a host listed in TRUSTED_PROXIES, in which case X-Forwarded-For is honored. If you run OpenWA behind a reverse proxy and use allowedIps, set TRUSTED_PROXIES so the real client IP is resolved. See Configuration.
Manage keys
API keys are managed under /api/auth/api-keys. Every management route requires an admin key with no session scope: a key carrying an allowedSessions allowlist is rejected with 403 Forbidden on every route in this controller, whatever its role, and the denial lands in the same api_key_auth_failed audit trail as any other rejected request.
Before v0.11.0 a session-scoped admin key could mint an unrestricted admin key, clear another key's scope, or enumerate every credential — a total, persistent escape from its fence. That path is closed. If one of your integrations manages keys with a scoped admin key, move it to an unrestricted admin key; unrestricted keys, including the bootstrap key, are unaffected.
A new key's full plaintext value is returned only in the creation response, under apiKey. Store it securely right away — it is hashed at rest and cannot be retrieved later. List and get responses return only keyPrefix (the first 12 characters, for example owa_k1_01234), never the full key.
Create a key
POST /api/auth/api-keys
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | Yes | Friendly label. |
role | "admin" | "operator" | "viewer" | No | Defaults to operator. |
allowedIps | string[] | No | IP / CIDR allowlist. |
allowedSessions | string[] | No | Session-id allowlist. |
expiresAt | string (ISO 8601) | No | Key stops validating after this time. |
curl -X POST http://localhost:2785/api/auth/api-keys \
-H "X-API-Key: YOUR_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Production Bot",
"role": "operator",
"allowedIps": ["203.0.113.50", "10.0.0.0/8"],
"allowedSessions": ["main"],
"expiresAt": "2027-12-31T23:59:59Z"
}'
The 201 Created response carries the one-time plaintext key in apiKey:
{
"id": "3f2a1c9e-1b2d-4a5f-9c8e-aa11bb22cc33",
"name": "Production Bot",
"keyPrefix": "owa_k1_01234",
"role": "operator",
"allowedIps": ["203.0.113.50", "10.0.0.0/8"],
"allowedSessions": ["main"],
"isActive": true,
"expiresAt": "2027-12-31T23:59:59.000Z",
"usageCount": 0,
"createdAt": "2026-06-25T09:30:00.000Z",
"apiKey": "owa_k1_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
Copy apiKey now. Every later response for this key omits it and returns only keyPrefix.
List, get, update, revoke, delete
| Operation | Request | Result |
|---|---|---|
| List all keys | GET /api/auth/api-keys | 200 array of keys (no plaintext). |
| Get one key | GET /api/auth/api-keys/{id} | 200 single key. |
| Update mutable fields | PUT /api/auth/api-keys/{id} | 200 updated key. |
| Revoke (deactivate) | POST /api/auth/api-keys/{id}/revoke | 200, sets isActive: false. |
| Delete permanently | DELETE /api/auth/api-keys/{id} | 204 No Content. |
PUT accepts the same fields as create (all optional): name, role, allowedIps, allowedSessions, expiresAt. Send only the fields you want to change. To replace a key's IP allowlist, PUT it with the new allowedIps array:
curl -X PUT http://localhost:2785/api/auth/api-keys/3f2a1c9e-1b2d-4a5f-9c8e-aa11bb22cc33 \
-H "X-API-Key: YOUR_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{ "allowedIps": ["203.0.113.50"] }'
To take a key out of service, revoke rather than delete it — revoke flips isActive to false (the key then fails with 401) while keeping the record:
curl -X POST http://localhost:2785/api/auth/api-keys/3f2a1c9e-1b2d-4a5f-9c8e-aa11bb22cc33/revoke \
-H "X-API-Key: YOUR_ADMIN_KEY"
{
"id": "3f2a1c9e-1b2d-4a5f-9c8e-aa11bb22cc33",
"name": "Production Bot",
"keyPrefix": "owa_k1_01234",
"role": "operator",
"isActive": false,
"usageCount": 412,
"createdAt": "2026-06-25T09:30:00.000Z"
}
Use DELETE only when you want the record gone entirely.
OpenWA refuses — with 409 Conflict — any operation that would strip the last usable admin: demoting, revoking, deleting, or expiring it, or applying a session scope to it. A usable admin is an active, unexpired admin key with no session scope. A session-scoped admin can authenticate but can never manage keys, so counting it as a survivor would bless a lockout with no in-band recovery. The check and the mutation share one lock, so two concurrent requests cannot race past the guard.
Creating, deleting, and revoking a key each write an api_key_created / api_key_deleted / api_key_revoked audit entry with the acting admin key, the client IP, and the target key. A failed authentication (invalid, disabled/expired, IP- or session-scope-denied, a scoped key on a key-management route, or insufficient role) writes an api_key_auth_failed entry with the client IP, method, path, and reason. Two further actions cover throttling and the queue dashboard: rate_limit_exceeded when a request or socket is shed by a rate limit — sampled to one row per minute per kind and subject, so the audit writes themselves cannot become the flood — and queue_board_mutated for every authenticated non-GET request to the Bull Board queue dashboard, whose 401/403 rejections now also write the standard api_key_auth_failed row. Audit logging is best-effort and never affects the request outcome. Review entries through GET /api/audit or the dashboard Logs page.
Check a key with /auth/validate
Any valid key can confirm its own validity and resolve its role. This is the quickest way to test that a key works and to read its role from a client.
POST /api/auth/validate
curl -X POST http://localhost:2785/api/auth/validate \
-H "X-API-Key: YOUR_API_KEY"
{ "valid": true, "role": "operator" }
A missing or invalid key returns 401 Unauthorized rather than { "valid": false }, because the global guard rejects it before this handler runs. The call does not double-count key usage, and an IP-restricted key validates correctly (a valid IP-pinned key is no longer reported as invalid). The SDK wraps the same call:
const { valid, role } = await client.auth();
// { valid: true, role: "operator" }
Authorize Swagger UI
The bundled Swagger UI (served by your instance) uses the same X-API-Key scheme. Click Authorize, paste your key, and every "Try it out" request carries it. There is no separate token — it is the same key you send with curl or the SDK.
Errors
| Status | When | Fix |
|---|---|---|
401 Unauthorized | Key missing, invalid, revoked (isActive: false), expired, or outside allowedIps / allowedSessions. | Send a valid, active key from an allowed IP and session. Check expiresAt. |
403 Forbidden | Key is valid but its role rank is below the route's requirement (for example a viewer key on a send route, or any non-admin key on /api/auth/api-keys), or a session-scoped key on a key-management route. | Use a key with a sufficient role — and no session scope for /api/auth/api-keys — or raise the key's role with PUT. |
404 Not Found | The {id} in a management route does not match an existing key. | List keys to find the correct id. |
A scope violation deliberately returns 401, not 403 — the request is treated as unauthenticated for that resource, so a session- or IP-restricted key reveals nothing about routes it cannot reach. The key-management controller is the exception: a scoped key there is authenticated but never permitted, so it gets 403.
Next steps
- Create your first session with an operator key.
- Set up webhooks — operator-level routes that consume these keys.
- Configuration —
API_MASTER_KEY,TRUSTED_PROXIES, and the seeded key. - API reference — the complete auth endpoint list and field schemas.