Skip to content

Applications & Authentication

The application registry

Pylon supports multiple applications on a single server instance. Each application is identified by a unique id and has its own key/secret pair. Applications are defined in a JSON file — by default apps.json (configurable via PYLON_APPS_PATH).

apps.json format

[
  {
    "name": "Example App",
    "id": "<your-app-id>",
    "key": "<your-app-key>",
    "secret": "<your-app-secret>",
    "enabled": true,
    "client_messages_enabled": true,
    "subscription_count_enabled": false,
    "capacity": 10000,
    "webhooks": [
      {
        "url": "https://example.test/pusher/webhooks",
        "event_types": [
          "channel_occupied",
          "channel_vacated",
          "member_added",
          "member_removed",
          "client_event",
          "cache_miss",
          "subscription_count"
        ],
        "headers": { "X-Custom": "value" }
      }
    ]
  }
]

Field reference

Field Type Description
name string Human-readable label for this app (not used in the protocol).
id string Unique app identifier. Included in REST API paths (/apps/{id}/...).
key string Public app key. Clients use this to identify the app when connecting.
secret string Shared secret for HMAC signing. Never sent to clients.
enabled boolean When false, the app is treated as if it did not exist: new connections are rejected and (with a DB-backed store) existing connections are force-closed. Defaults to true.
client_messages_enabled boolean When true, clients may publish events to channels via client_event. Defaults to false.
subscription_count_enabled boolean When true, the server emits pusher_internal:subscription_count events as a channel's subscriber count changes, and the subscription_count webhook (if the endpoint also lists it in event_types). Defaults to false.
capacity integer Maximum concurrent WebSocket connections for this app (0 = unlimited). Connections beyond this limit are refused with WebSocket close code 4004.
max_backend_events_per_second integer or null Per-app override for PYLON_MAX_BACKEND_EVENTS_PER_SECOND. Absent or null = use the server default; 0 = unlimited for this app. The two are different states, so omitting the field is not the same as setting it to 0.
max_read_requests_per_second integer or null Per-app override for PYLON_MAX_READ_REQUESTS_PER_SECOND, with the same absent/0 distinction.
webhooks array Zero or more webhook targets. Each entry has a url, an event_types list, and an optional headers map. See the Webhooks page for the full event-type reference.

Note

Unknown fields in apps.json are ignored. Earlier examples included host, path, and statistics_enabled — these are not read by Pylon and have no effect; they are safe to leave in an existing file but are no longer documented.

id, key, and secret are validated

An empty or whitespace-only id, key, or secret is rejected — a blank secret is a zero-length HMAC key, and since key is public by design (it ships in browser bundles), a blank secret would let anyone holding the key forge signed requests. An app key containing a colon (:) is rejected too: the channel-auth and pusher:signin tokens are <key>:<signature> and both verifiers split at the first colon, so a key like team:web made every private and presence subscribe answer "Auth key mismatch" and closed every pusher:signin with 4009, permanently — while REST kept working, so it looked like a client-library bug. For apps.json, id and key must also each be unique across the file; a duplicate fails the whole file at startup, naming the offending value. This validation runs again on every lookup against a database-backed store (not just at load), so a row with a blank field fails every lookup against it rather than only the first — see Database-backed app stores below.


Database-backed app stores

apps.json is ideal for a fixed set of applications. For a SaaS-scale deployment — apps provisioned by a control plane, or more apps than fit comfortably in a file — Pylon can read applications from a database instead. Set PYLON_APP_MANAGER and PYLON_APP_DSN:

PYLON_APP_MANAGER Example PYLON_APP_DSN
sqlite sqlite:///var/lib/pylon/apps.db (single-node / edge / dev)
mysql mysql://user:pass@db-host:3306/pylon
postgres postgres://user:pass@db-host:5432/pylon
mongo mongodb://db-host:27017/pylon

Pylon only reads the application store — provisioning (creating, updating, deleting apps) is your control plane's job. The lookup path is the same as for apps.json: an app's record is resolved once at connection establish and once per REST publish, never per message.

Schema

The relational drivers expect an apps table; the columns mirror the field reference above. Ready-to-run DDL ships in the repository under deploy/db/ — one file per engine:

-- deploy/db/postgres/001_apps.sql (MySQL/SQLite equivalents alongside)
CREATE TABLE IF NOT EXISTS apps (
    id          VARCHAR(255) NOT NULL PRIMARY KEY,
    key         VARCHAR(255) NOT NULL UNIQUE,
    secret      VARCHAR(255) NOT NULL,
    name        VARCHAR(255) NOT NULL DEFAULT '',
    capacity    BIGINT NOT NULL DEFAULT 0,
    max_backend_events_per_second  BIGINT NULL,              -- NULL = server default
    max_read_requests_per_second   BIGINT NULL,              -- NULL = server default
    client_messages_enabled     BIGINT NOT NULL DEFAULT 0,   -- 0/1
    subscription_count_enabled  BIGINT NOT NULL DEFAULT 0,   -- 0/1
    enabled     BIGINT NOT NULL DEFAULT 1,                   -- 0/1
    webhooks    TEXT NOT NULL DEFAULT '[]',                  -- JSON array
    updated_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

The unique index on key and the primary key on id make both lookups index hits. Boolean columns are stored as 0/1 integers and webhooks as a JSON array string (the same shape as the apps.json webhooks field). MongoDB uses an apps collection with the same fields and unique indexes on id and key (see deploy/db/mongo/001_indexes.js).

Caching

A DB-backed store is fronted by a two-tier cache so per-connection lookups stay fast even with an unbounded app catalogue:

  • L1 (in-process): a bounded cache (TinyLFU, per-entry TTL, single-flight) — a warm hit is ~hundreds of nanoseconds, faster than scanning a large file. Concurrent misses for the same app collapse into one database query, so a connection storm to a cold app does not stampede the DB.
  • L2 (Redis, optional): set PYLON_APP_CACHE_REDIS_URL to share warm apps across nodes and survive restarts — a cold node reads from Redis instead of the database.
  • Negative cache: a separate, smaller, short-TTL cache holds "no such app" so a flood of bad keys can never evict real apps and never reaches the database.

A backend outage is distinguished from a genuinely-unusable app: a missing app is rejected fatally (WS 4001), a disabled app with its own fatal code (WS 4003), while a transient DB/Redis error is rejected retryably (WS 4103) and never negatively cached, so clients reconnect and succeed when the backend recovers. See Configuration → Application store for the cache tuning variables.

Keeping the cache correct

Every cache entry has a TTL, so the worst-case staleness is bounded even if no signal ever arrives. To apply a change immediately (a rotated secret, a disabled or deleted app), tell Pylon to invalidate its cache. With PYLON_APP_CACHE_REDIS_URL set, an authenticated admin call publishes the invalidation to every node:

# Refresh: re-fetch this app on the next lookup (e.g. after a config/secret change).
curl -X POST "http://pylon:7000/admin/apps/<app-id>/invalidate" \
  -H "Authorization: Bearer $PYLON_ADMIN_TOKEN" \
  -d '{"key":"<app-key>","action":"refresh"}'

# Remove: the app is gone — every node evicts it AND force-closes all of its live
# connections with WebSocket close code 4009.
curl -X POST "http://pylon:7000/admin/apps/<app-id>/invalidate" \
  -H "Authorization: Bearer $PYLON_ADMIN_TOKEN" \
  -d '{"key":"<app-key>","action":"remove"}'

action defaults to refresh if omitted. The admin API is disabled (404) unless PYLON_ADMIN_TOKEN is set, requires the bearer token (constant-time compared), and requires PYLON_APP_CACHE_REDIS_URL for cross-node delivery (otherwise it returns 503). Your control plane can call this endpoint on any app write, or publish to the pylon:app:invalidate Redis channel directly.

As a backstop, set PYLON_APP_SWEEP_INTERVAL to a number of seconds: Pylon then periodically re-checks the currently-connected apps against the database and force-closes any that have been disabled or deleted, even if an invalidation signal was missed.


Key and secret usage

Clients use the key to connect. A Pusher-compatible client library is initialised with the app key and optionally a cluster/host pointing at your Pylon server:

const pusher = new Pusher("<your-app-key>", {
  wsHost: "pylon.example.com",
  wsPort: 7000,
  forceTLS: false,
  enabledTransports: ["ws"],
});

Server-side code uses both the key and secret to authenticate REST calls and to generate subscription auth tokens for private and presence channels. The Pusher HTTP client libraries (pusher-http-node, pusher-http-python, etc.) accept these as appId, key, and secret constructor parameters.


HMAC authentication model

Pylon uses HMAC-SHA256 for all authentication operations, matching the Pusher v7 protocol. The secret is the shared HMAC key; it never leaves the server.

Private and presence channels

Clients subscribing to a private-* or presence-* channel must first obtain an auth token from your own backend server. The backend signs the subscription using HMAC-SHA256 and returns a token of the form <app_key>:<hex_signature>.

The signing strings are:

  • Private channel: HMAC-SHA256(secret, "<socket_id>:<channel>")
  • Presence channel: HMAC-SHA256(secret, "<socket_id>:<channel>:<channel_data>") where channel_data is the verbatim JSON string containing at minimum {"user_id": "..."}.

Pylon verifies this token with a constant-time comparison before allowing the subscription.

User sign-in (pusher:signin)

The pusher:signin flow lets a client authenticate as a named user. The signing string is:

HMAC-SHA256(secret, "<socket_id>::user::<user_data>")

where user_data is the verbatim JSON string the client sends (must contain "id").

REST authentication

HTTP requests to the Pylon REST API (triggering events, querying channel state) are authenticated using HMAC-SHA256 over a canonical query string derived from the request method, path, and parameters. REST signing details are covered on the Triggering Events page.


Per-app connection capacity

The capacity field sets a hard ceiling on concurrent WebSocket connections for that app. When a new connection would push the count over the limit, Pylon sends a WebSocket close frame with code 4004 (capacity exceeded) and refuses the connection.

In single-node mode the limit is enforced by that node's local connection count for the app. With the Redis adapter the limit is enforced cluster-wide: after the fast node-local check passes, the admission decision is completed in Redis — an atomic check-and-increment against a per-app cluster counter ({prefix}:appconns) shared by every node — so N nodes jointly hold the app to capacity connections, not N × capacity.

Two operational notes for clustered deployments:

  • Bridge fail-open. The Redis admission runs on the node's cluster bridge. If that bridge is momentarily unavailable (channel saturated, bridge stalled, or a transient Redis error), the connection is admitted anyway rather than locked out — during such a blip each node falls back to enforcing its local count only, so the cluster-wide ceiling may be exceeded by up to the blip's worth of admissions. If a Redis outage outlasts the per-node capacity-hash TTL (~65s at the defaults), the counters self-heal when Redis comes back: each node's heartbeat re-seeds its per-app counts in Redis from that node's live local connections, so the cluster totals resume from truth. (Connections that closed during the outage still leak their single unit each — the same bounded one-unit leak as any dropped release.)
  • Crashed nodes. A node that dies without closing its connections still holds its capacity units in Redis until the sweeper reclaims them. Reclaim timing is heartbeat-based: the dead node's heartbeat key expires (3 × PYLON_REDIS_NODE_HEARTBEAT after its last beat), the sweep lease it may still hold expires after that, and the next sweep pass frees its counts — worst case 3 × heartbeat + lease + one sweep interval ≈ 55 seconds at the defaults (5s heartbeat, 10s sweep; the lease is max(3 × sweep, 5s)), typically faster.

Set capacity to 0 to disable the limit (unrestricted). For most production deployments, sizing capacity to match your expected peak concurrent users plus a comfortable headroom is recommended.


Per-app REST rate limits

max_backend_events_per_second and max_read_requests_per_second override the node-wide defaults (PYLON_MAX_BACKEND_EVENTS_PER_SECOND, PYLON_MAX_READ_REQUESTS_PER_SECOND) for one app, so a single tenant cannot spend the whole node's publish budget. A request over the app's limit is answered 429 with Retry-After, X-RateLimit-Limit and X-RateLimit-Remaining, and pylon_rest_rate_limited_total{scope="app_events"} / {scope="app_reads"} increments. See Production Tuning for how to pick the numbers.

An absent value (the JSON field omitted, or a NULL column) means "use the server default"; an explicit 0 means unlimited for this app. Those are different states — omitting the field is not the same as writing 0.

A POST /batch_events costs its event count against max_backend_events_per_second, so a value below PYLON_MAX_BATCH_EVENTS makes a full-size batch permanently unaffordable. Pylon refuses to start when the server-wide PYLON_MAX_BACKEND_EVENTS_PER_SECOND is non-zero and below the batch cap; a per-app override is not validated against it, so that one is yours to keep above the cap.

Every limit is enforced per node, with no cluster coordination: unlike capacity, which the Redis adapter enforces cluster-wide, N nodes behind a load balancer jointly allow N × max_backend_events_per_second. Divide by your node count if you need a cluster-wide ceiling.

Relational stores: the columns are optional

The two columns are read only when the apps table has them. Pylon probes for them once, at startup, and an apps table predating this release keeps working unchanged — every app simply resolves both overrides as absent and uses the server defaults. Because the probe runs once, adding the columns to a live database takes effect at the next restart, not immediately.

A changed value takes effect on that app's next REST request — a node notices that its cached bucket was built for a different limit and replaces it — so no restart is needed. The replacement bucket starts full, so lowering a limit does not retroactively charge traffic already served.

A negative value is an invalid app, not "unlimited": the app fails to load and the failure names the field. Use 0 for unlimited.


Security: keep apps.json out of version control

apps.json contains the secret for each app — treat it like a password file.

The repo's .gitignore already excludes apps.json. Keep it that way: do not commit apps.json to version control. Use a secrets manager, environment-specific config injection, or a mounted secret volume to deliver the file at runtime.

If a secret is ever exposed, generate a new key/secret pair and update apps.json — all currently connected clients will need to re-authenticate.