---
title: HTTP Auth Contract
description: "Plug kraken into your own user store: the three JSON endpoints kraken's http auth backend calls (validate, revalidate, check-room-access), the client attributes it reads, and a minimal working service."
---

# HTTP Auth Contract

With `AUTH_BACKEND=http`, kraken asks an HTTP service of yours whether a token is valid and what it may reach. The contract is three JSON endpoints. `@nolag/core`'s example host implements them, and so can anything else.

| Setting | Meaning |
| --- | --- |
| `AUTH_BACKEND=http` | Selects this backend |
| `AUTH_HTTP_URL` | Base URL. kraken appends `/validate`, `/revalidate` and `/check-room-access`, keeping any path in the base, so `http://core:3000/v1/internal/actors` works. |
| `BACKEND_SECRET` | Sent on every call as `Authorization: Bearer <secret>`. Check it, and keep the service off public networks. |

Every call is a `POST` with a JSON body. kraken waits up to 5 seconds for `/validate` and `/revalidate`, and 2 seconds for `/check-room-access`.

## POST /validate

Called when a client connects or sends a `reauth`, unless kraken validated the same token in the last 30 seconds.

```json
{ "accessToken": "the token the client sent" }
```

Answer `200` with one of:

```json
{ "result": "allow", "client_attrs": { "actor_token_id": "bob", "apps": [] } }
```

```json
{ "result": "deny" }
```

| Your service | The client's `connect()` fails with |
| --- | --- |
| `200` with `"result": "deny"` | `access_denied` |
| Any status other than `200` | `authentication_failed` |
| No answer within 5 seconds, or unreachable | `connection_failed` |

A denial is a valid answer, not an error, so return it with `200`. `@nolag/core` does the same.

### Client attributes

What kraken v0.9.0 reads from an `allow` answer:

| Key | Meaning |
| --- | --- |
| `actor_token_id` | Required. The actor's id: reported to the client, used for presence, revalidation and default load-balance groups. |
| `project_id` | Reported to the client in the `auth` reply; part of load-balance group names. |
| `organization_id` | The group kraken counts connections in for `max_connections`. |
| `actor_type` | Default `user`. Reported to the client. |
| `apps` | The actor's grants, grouped by app (below). |
| `max_connections` | Connection limit for the `organization_id`, counted across the cluster. `null` or absent means unlimited. |
| `scope_slug` | An access scope. kraken rewrites a client's `app/room/topic` to `app/<scope>/room/topic` when only the scoped address matches the grants. See [Access Scopes](/docs/scopes). |
| `auth_expires_at` | Unix seconds. kraken refuses the connect with `token_expired` once it has passed, and closes a live connection with code `4003` at its first heartbeat after it. Set it for short-lived tokens; omit it for long-lived ones. |
| `persistent_session`, `session_expiry_seconds` | Ask the MQTT broker backend for a non-clean session with this expiry. Ignored by the default `syn` broker. |
| `max_message_size_bytes` | Accepted, but not enforced in v0.9.0: every publish is held to the fixed 921,600 byte ceiling. |

Each entry in `apps`:

| Key | Meaning |
| --- | --- |
| `app_id` | The app's id. Grants in this app resolve to internal topics under it, so every actor of the app must get the same value. |
| `app_name` | Descriptive |
| `allowed_topics` | The grants: `{ "pattern", "permission", "topic", "room_id", "room_slug" }`. `pattern` is `app/room/topic` and may use `+` and `#`; `permission` is `subscribe`, `publish` or `pubSub`; `room_id` and `room_slug` are needed for presence and lobbies; `topic` optionally names the internal topic for an exact pattern. |
| `allowed_lobbies` | `[{ "lobby_slug", "lobby_id" }]` |
| `active_subscriptions` | Subscriptions kraken restores when the client reconnects: addresses, or `{ "pattern", "topic", "load_balance", "load_balance_group", "filters" }` |
| `hydration_webhook`, `trigger_webhook` | `{ "url", "headers" }` or `null`. See [Webhooks](/docs/concepts/webhooks). |
| `topic_webhooks` | Per-topic overrides: `{ "<topic>": { "on_subscribe": { ... }, "on_publish": { ... } } }` |

How grants resolve to internal topics, and why every actor of an app must agree on `app_id` and `topic`, is the same as for the static file: see [How grants turn into topics](/docs/self-hosting/static-auth#how-grants-turn-into-topics). Unlike the static file, an HTTP answer can carry several apps, and each grant keeps its own app's id.

## POST /revalidate

Called for each live connection on a heartbeat when at least 10 minutes have passed since it was last validated.

```json
{ "actorTokenId": "bob" }
```

| Your answer | What kraken does |
| --- | --- |
| `200` `{ "valid": true, ...client_attrs... }` with the attributes at the top level | Replaces the connection's grants with the new ones |
| `200` `{ "valid": false, "disconnect_reason": "token_revoked" }` | Closes the connection with WebSocket close code `4001` and the reason as the close reason (falling back to `error`, then `unknown`) |
| Any other status, a timeout, or no answer | Keeps the connection, and tries again at the next heartbeat |

We checked the second row against kraken v0.9.0 with the service below: a connection whose actor the service had revoked was closed 600.1 seconds after it connected, with code `4001` and reason `token_revoked`, and no frame before the close.

## Check room access

`POST /check-room-access` is optional, and called in one situation only: a client subscribes to an address that none of its cached grants covers, typically a room created after it connected.

```json
{ "actorTokenId": "bob", "pattern": "chat/late/messages" }
```

```json
{
  "allow": true,
  "allowed_topics": [
    { "pattern": "chat/late/#", "permission": "pubSub", "app_id": "chat-app", "room_id": "room-late", "room_slug": "late" }
  ]
}
```

On `allow: true`, kraken adds the returned grants to the connection and accepts the subscribe, provided one of them covers the address; each grant needs its `app_id`. Anything else (`allow: false`, an error status, no answer within 2 seconds, or a service without the endpoint) refuses the subscribe with `unknown_topic`. kraken remembers a refusal for 5 seconds per actor and address, and while your service is unreachable a circuit breaker skips the call altogether.

This applies to subscribes only. A **publish** outside the cached grants fails with `unknown_topic` without calling your service, until the next revalidation brings the new grants or the client reconnects. To switch the check off, set `cache_miss_fallback_enabled` to `false` in kraken's `sys.config`.

## A minimal service

This Node.js service implements all three endpoints for two hard-coded tokens. It uses only the standard library:

```js [auth-service.mjs]
import http from "node:http";

const SECRET = process.env.BACKEND_SECRET;

// Your user store. Here: two hard-coded tokens.
const actors = {
  "token-for-alice": { id: "alice", active: true },
  "token-for-bob": { id: "bob", active: true },
};

// Everything kraken needs to know about one actor.
function clientAttrs(actorTokenId) {
  return {
    actor_token_id: actorTokenId,
    project_id: "my-project",
    organization_id: "my-org",
    actor_type: "user",
    apps: [
      {
        app_id: "chat-app",
        app_name: "chat",
        allowed_topics: [
          { pattern: "chat/general/#", permission: "pubSub", room_id: "room-general", room_slug: "general" },
        ],
      },
    ],
  };
}

const byId = (id) => Object.values(actors).find((a) => a.id === id);

const routes = {
  "/validate": ({ accessToken }) => {
    const actor = actors[accessToken];
    return actor?.active
      ? { result: "allow", client_attrs: clientAttrs(actor.id) }
      : { result: "deny" };
  },
  "/revalidate": ({ actorTokenId }) => {
    const actor = byId(actorTokenId);
    return actor?.active
      ? { valid: true, ...clientAttrs(actorTokenId) }
      : { valid: false, disconnect_reason: "token_revoked" };
  },
  "/check-room-access": ({ actorTokenId, pattern }) => {
    // Called when a subscribe misses the cached grants. Allow chat/late/<topic>.
    const [app, room] = pattern.split("/");
    if (byId(actorTokenId)?.active && app === "chat" && room === "late") {
      return {
        allow: true,
        allowed_topics: [
          { pattern: "chat/late/#", permission: "pubSub", app_id: "chat-app", room_id: "room-late", room_slug: "late" },
        ],
      };
    }
    return { allow: false, allowed_topics: [] };
  },
};

http
  .createServer((req, res) => {
    let body = "";
    req.on("data", (chunk) => (body += chunk));
    req.on("end", () => {
      const route = routes[req.url];
      const authorized = req.headers.authorization === `Bearer ${SECRET}`;
      const status = !authorized ? 401 : route ? 200 : 404;
      const reply = status === 200 ? route(JSON.parse(body || "{}")) : {};
      res.writeHead(status, { "content-type": "application/json" });
      res.end(JSON.stringify(reply));
    });
  })
  .listen(4000, () => console.log("auth service on :4000"));
```

Run it, then build kraken (in your kraken checkout) and start it pointed at the service:

```bash
BACKEND_SECRET=change-me node auth-service.mjs
```

```bash
docker build -t kraken:0.9.0 .
docker run --rm -p 8080:8080 \
  -e AUTH_BACKEND=http \
  -e AUTH_HTTP_URL=http://host.docker.internal:4000 \
  -e BACKEND_SECRET=change-me \
  --add-host host.docker.internal:host-gateway \
  kraken:0.9.0
```

What we saw with this pair, connecting with `@nolag/js-sdk`:

- `token-for-bob` connected as actor `bob`; an unknown token was refused with `access_denied`.
- bob's subscribe to `chat/general/messages` was accepted from his grants, and to `chat/late/messages` through `/check-room-access`. A subscribe to `chat/secret/messages` was refused with `unknown_topic`.
- alice's publish to `chat/general/messages` reached bob. Her publish to `chat/late/messages` was refused with `unknown_topic`, because publishes do not trigger the room-access check.

A real service would look tokens up in a database, compare secrets in constant time, and return the actor's real grants. It should also answer `/validate` quickly: every connect waits for it.

## Using @nolag/core

`@nolag/core` implements this contract through `AuthzFacade` and its `toBroker*` response adapters, and its example host serves it under `/v1/internal/actors`. Point kraken at it with:

```text
AUTH_BACKEND=http
AUTH_HTTP_URL=http://core:3000/v1/internal/actors
```

The example host does **not** check `BACKEND_SECRET`, or any other credential. A host you write for production should require the secret, and kraken will send it. See [Full Stack](/docs/self-hosting/full-stack).

## Restoring subscriptions

kraken restores what your service returns in `active_subscriptions` when a client reconnects; it keeps no record of its own. To learn what clients subscribed to, run kraken with `CONTROL_BACKEND=http` and `CONTROL_HTTP_URL`: it then posts batches of subscribe and unsubscribe events to `{CONTROL_HTTP_URL}/subscriptions`, which your service can store and hand back. The shape of those calls is in [Plugins](/docs/self-hosting/plugins#control). `@nolag/core`'s example host does not implement them, which is why the full-stack quickstart does not restore subscriptions.
