Skip to main content
Version: v0.23.1

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.

Prerequisites

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();
Bearer also works

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.

WebSocket handshake no longer accepts ?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.

WebSocket connections are rate-limited

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.

RoleRankCan do
viewer1Read-only routes — list sessions, read messages, view contacts and groups.
operator2Everything viewer can, plus write and action routes — send messages, manage groups, manage webhooks.
admin3Everything, 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.

FieldTypeEffect
allowedSessionsstring[]Session ids the key may touch. A scoped key acts only on these sessions; list endpoints return only their data.
allowedIpsstring[]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).

Trust your proxy chain

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.

Scoped admin keys can no longer manage keys — breaking in v0.11.0

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.

The plaintext key is shown once

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

FieldTypeRequiredNotes
namestringYesFriendly label.
role"admin" | "operator" | "viewer"NoDefaults to operator.
allowedIpsstring[]NoIP / CIDR allowlist.
allowedSessionsstring[]NoSession-id allowlist.
expiresAtstring (ISO 8601)NoKey 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

OperationRequestResult
List all keysGET /api/auth/api-keys200 array of keys (no plaintext).
Get one keyGET /api/auth/api-keys/{id}200 single key.
Update mutable fieldsPUT /api/auth/api-keys/{id}200 updated key.
Revoke (deactivate)POST /api/auth/api-keys/{id}/revoke200, sets isActive: false.
Delete permanentlyDELETE /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.

The last usable admin is protected

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.

Key operations are audited

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

StatusWhenFix
401 UnauthorizedKey missing, invalid, revoked (isActive: false), expired, or outside allowedIps / allowedSessions.Send a valid, active key from an allowed IP and session. Check expiresAt.
403 ForbiddenKey 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 FoundThe {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