Realtime authentication
Realtime supports a full auth ladder — from fully open to enterprise SSO — so you pick the level that fits each interface. The organisation API key is a backend-only credential; never ship it to a browser.
| Mode | How | Use when |
|---|---|---|
| Open / public | public: true on the interface — no credential | Non-sensitive public feeds (scores, status, prices) |
| Air Pipe issues the token | Users log in through an Air Pipe interface; add_jwt mints a JWT | You have no identity provider |
| Your JWT — shared secret | Client sends its JWT; verified with is_valid_jwt (HS256) | You have a backend, no IdP |
| Your JWT — JWKS | Verified against your IdP's JWKS (RS256/ES256) | You already have Auth0/Cognito/etc. |
| Backend API key | Authorization: Bearer <key> (server-to-server) | Trusted backend publishing |
How end-users present a credential
- WebSocket, browser:
?token=<jwt>on the URL — browsers can't set WebSocket headers. - WebSocket, native: the
airpipe-jwtheader (or?token=). - Backend (WS): the org API key via
Authorization: Bearer,x-api-key, or?api_key=. - MQTT:
username = <ORG_UUID>,password = <API_KEY>— or no password for public.
The interface authenticates the user in-pipeline (an is_valid_jwt assert), and channel access is
gated by subscribe_authorizer — so the ACL is your pipeline logic on your claims.
What a socket may do on a channel
| Socket | No subscribe_authorizer | With a subscribe_authorizer |
|---|---|---|
| Backend (org API key) | Any channel in the org | Publish and presence list: any channel. Subscribe and presence enter: what the authorizer allows |
| End user (a token) | Nothing | What the authorizer allows |
Anyone on a public: true interface | Subscribe only | What the authorizer allows (it needs a token, so a socket without one gets nothing) |
The authorizer runs once for each channel a socket subscribes to, publishes to, enters or lists, with
the socket's token as a|headers::airpipe-jwt| and this body:
{ "channel": "room:42", "action": "subscribe" }
action is subscribe, publish, presence_enter or presence_get, so one authorizer can let a user
read a channel without letting them write to it. It also runs once when an end user's socket opens,
with "action": "connect" and "channel": null: a token it refuses is refused right there (401) and
never takes one of your organization's connection slots. So an authorizer must let connect through for
the tokens it accepts:
authorize-channel:
actions:
- name: Verify
input: a|headers|
assert:
http_code_on_error: 403
tests:
- value: airpipe-jwt
is_valid_jwt: a|ap_var::JWT_SECRET|
- name: ReadOnly # end users may only listen; your backend publishes
run_when_succeeded: { actions: [Verify], http_code_on_error: 403 }
input: a|body|
assert:
http_code_on_error: 403
tests:
- value: action
is_in: [connect, subscribe, presence_get]
A refused operation answers {"ap_error": "subscribe denied" | "publish denied" | "presence denied", …}
and nothing reaches the channel. This applies on managed (hosted) orgs; a self-hosted engine treats
every socket as your own backend.
Open / public
public: true lets a client connect with no credential at all — the Ably/Pusher "public channel"
model. Such a socket may subscribe; publishing stays with your backend (the org API key or a
ws_publish action). Safe on shared infra: the per-org abuse floors still bound a
public interface, and (managed) the connection stays scoped to your org.
feed:
ws: "on"
public: true
actions:
- name: Ping
input: a|body|
Public MQTT works the same way: an anonymous MQTT client (no password) may pub/sub only to
public: true interfaces — never a private topic (enforced fail-closed at subscribe and publish). On
managed hosts pass username = <ORG_UUID> with no password so the stream is scoped + accounted to your
org; pure self-hosted allows fully anonymous (single-tenant, like EMQX allow_anonymous).
Air Pipe issues the token
No IdP? Log users in through an Air Pipe interface and mint a JWT with add_jwt; Air Pipe verifies its
own token on the socket.
login:
output: http
method: POST
actions:
- name: Validate # replace with a real credential check (DB lookup + bcrypt)
input: a|body|
assert:
tests: [{ value: username, is_not_null: true }, { value: password, is_not_null: true }]
- name: Mint
run_when_succeeded: { actions: [Validate] }
input: a|Validate|
post_transforms:
- add_jwt: { key: token, secret: a|ap_var::JWT_SECRET|, exp: 24h, data: [username] }
The browser opens wss://…/ws/<org>/production/live?token=<the token>; authorize-channel verifies it
with the same secret.