Secure AI applications require agents to trust who sent each message and understand what that sender is authorized to do. Ably's identity system uses token-based authentication to provide cryptographically-verified identities with custom attributes that you can access throughout your applications.
Why identity matters
In decoupled architectures, identity serves several critical purposes:
- Prevent spoofing: Without verified identity, malicious users could impersonate others by claiming to be someone else. Ably supports cryptographically binding each client's identity to their credentials, making spoofing impossible.
- Message attribution: Agents need to know whether messages come from users or other agents. This is essential for conversation flows in which agent responses should be securely distinguished from user prompts.
- Personalized behavior: Different users may have different privileges or attributes. A premium user might get access to more capable models, while a free user gets basic functionality. Ably allows your trusted authentication server to embed this information in the client's credentials, allowing this information to be securely passed to agents.
- Authorization decisions: Some operations should only be performed for specific users. For example, human-in-the-loop (HITL) tool calls that access sensitive data might require admin privileges. Ably allows agents to verify the privilege level and role of the user resolving the tool call.
Authenticating users
Use token authentication to authenticate users securely. Your authentication server generates a token that is signed with the secret part of your Ably API key. Clients use this token to connect to Ably, and the token signature ensures it cannot be tampered with.
The following examples use JWT authentication for its simplicity and standard tooling support. For other approaches, see token authentication.
Create a server endpoint that generates signed JWTs after verifying user authentication:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Server code
import express from "express";
import jwt from "jsonwebtoken";
const app = express();
// Mock authentication middleware.
// This should be replaced with your actual authentication logic.
function authenticateUser(req, res, next) {
// Assign a mock user ID for demonstration
req.session = { userId: "user123" };
next();
}
// Return the claims payload to embed in the signed JWT.
function getJWTClaims(userId) {
// Returns an empty payload, so the token
// inherits the capabilities of the signing key.
return {};
}
// Define an auth endpoint used by the client to obtain a signed JWT
// which it can use to authenticate with the Ably service.
app.get("/api/auth/token", authenticateUser, (req, res) => {
const [keyName, keySecret] = "demokey:*****".split(":");
// Sign a JWT using the secret part of the Ably API key.
const token = jwt.sign(getJWTClaims(req.session.userId), keySecret, {
algorithm: "HS256",
keyid: keyName,
expiresIn: "1h",
});
res.type("application/jwt").send(token);
});
app.listen(3001);The JWT is signed with the secret part of your Ably API key using HMAC-SHA-256. This example does not embed any claims in the JWT payload, so by default the token inherits the capabilities of the Ably API key used to sign the token.
Configure your client to obtain a signed JWT from your server endpoint using an authCallback. The client obtains a signed JWT from the callback and uses it to authenticate requests to Ably. The client automatically makes a request for a new token before it expires.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Client code
import * as Ably from "ably";
const ably = new Ably.Realtime({
authCallback: async (tokenParams, callback) => {
try {
const response = await fetch("/api/auth/token");
const token = await response.text();
callback(null, token);
} catch (error) {
callback(error, null);
}
}
});
ably.connection.on("connected", () => {
console.log("Connected to Ably");
});Authenticating agents
Agents typically run on servers in trusted environments where API keys can be securely stored. Use API key authentication to authenticate agents directly with Ably.
1
2
3
4
5
6
7
8
9
10
// Agent code
import * as Ably from "ably";
const ably = new Ably.Realtime({
key: "demokey:*****"
});
ably.connection.on("connected", () => {
console.log("Connected to Ably");
});Specifying capabilities
Use capabilities to specify which operations clients can perform on which channels. This applies to both users and agents, allowing you to enforce fine-grained permissions.
User capabilities
Add the x-ably-capability claim to your JWT to specify the allowed capabilities of a client. This allows you to enforce fine-grained permissions, such as restricting some users to only subscribe to messages while allowing others to publish.
Update your getJWTClaims function to specify the allowed capabilities for the authenticated user:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Server code
// Return the claims payload to embed in the signed JWT.
// Includes the `x-ably-capabilities` claim, which controls
// which operations the user can perform on which channels.
function getJWTClaims(userId) {
const orgId = "acme"; // Mock organization ID for demonstration
const capabilities = {
// The user can publish and subscribe to channels within the organization,
// that is, any channel matching `org:acme:*`.
[`org:${orgId}:*`]: ["publish", "subscribe"],
// The user can only subscribe to the `announcements` channel.
announcements: ["subscribe"],
};
return {
"x-ably-capability": JSON.stringify(capabilities),
};
}When a client authenticates with this token, Ably enforces these capabilities server-side. Any attempt to perform unauthorized operations will be rejected. For example, a client with the capabilities above can publish to channels prefixed with org:acme:, but an attempt to publish to a channel prefixed with org:foobar: will fail with error code 40160:
1
2
3
4
5
6
7
8
9
10
// Client code
const acmeChannel = ably.channels.get("org:acme:job-map-new");
await acmeChannel.publish("prompt", "What is the weather like today?"); // succeeds
const foobarChannel = ably.channels.get("org:foobar:job-map-new");
await foobarChannel.publish("prompt", "What is the weather like today?"); // fails
const announcementsChannel = ably.channels.get("announcements");
await announcementsChannel.publish("prompt", "What is the weather like today?"); // fails
await announcementsChannel.subscribe((msg) => console.log(msg)); // succeedsAgent capabilities
When using API key authentication, provision API keys through the Ably dashboard or Control API with only the capabilities required by the agent.
The following example uses the Control API to create an API key with specific capabilities for a weather agent:
curl --location --request POST 'https://control.ably.net/v1/apps/{{APP_ID}}/keys' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer ${ACCESS_TOKEN}' \
--data-raw '{
"name": "weather-agent-key",
"capability": {
"org:acme:weather:*": ["publish", "subscribe"]
}
}'This creates an API key that can only publish and subscribe on channels matching org:acme:weather:*. The agent can then use this key to authenticate:
1
2
3
4
5
6
7
8
// Agent code
const weatherChannel = ably.channels.get("org:acme:weather:job-map-new");
await weatherChannel.subscribe((msg) => console.log(msg)); // succeeds
await weatherChannel.publish("update", "It's raining in London"); // succeeds
const otherChannel = ably.channels.get("org:acme:other:job-map-new");
await otherChannel.subscribe((msg) => console.log(msg)); // fails
await otherChannel.publish("update", "It's raining in London"); // failsEstablishing verified identity
Use the clientId to identify the user or agent that published a message. The method for setting clientId depends on your authentication approach:
- When using basic authentication, specify the
clientIddirectly in the client options when instantiating the client instance. - When using token authentication, specify an explicit
clientIdwhen issuing the token.
User identity
Users typically authenticate using token authentication. Add the x-ably-clientId claim to your JWT to establish a verified identity for each user client. This identity appears as the clientId in all messages the user publishes, and subscribers can trust this identity because only your server can issue JWTs with specific clientId values.
As with all clients, the method for setting clientId depends on your authentication approach.
Update your getJWTClaims function to specify a clientId for the user:
1
2
3
4
5
6
7
8
9
// Server code
// Return the claims payload to embed in the signed JWT.
function getJWTClaims(userId) {
// Returns a payload with the `x-ably-clientId` claim, which ensures
// that the user's ID appears as the `clientId` on all messages
// published by the client using this token.
return { "x-ably-clientId": userId };
}When a client authenticates using this token, Ably's servers automatically attach the clientId specified in the token to every message the user publishes:
1
2
3
4
5
// Client code
const channel = ably.channels.get("job-map-new");
// Publish a message - the clientId is automatically attached
await channel.publish("prompt", "What is the weather like today?");Agents can then access this verified identity to identify the sender:
1
2
3
4
5
6
7
8
9
10
11
12
// Agent code
const channel = ably.channels.get("job-map-new");
// Subscribe to messages from clients
await channel.subscribe("prompt", (message) => {
// Access the verified clientId from the message
const userId = message.clientId;
const prompt = message.data;
console.log(`Received message from user: ${userId}`);
console.log(`Prompt:`, prompt);
});The clientId in the message can be trusted, so agents can use this identity to make decisions about what actions the user can take. For example, agents can check user permissions before executing tool calls, route messages to appropriate AI models based on subscription tiers, or maintain per-user conversation history and context.
Agent identity
Agent code typically runs in a trusted environment, so you can use basic authentication and directly specify the clientId when instantiating the agent client. This identity appears as the clientId in all messages the agent publishes, allowing subscribers to identify the agent which published a message.
1
2
3
4
5
6
7
8
// Agent code
import * as Ably from "ably";
const ably = new Ably.Realtime({
key: "demokey:*****",
// Specify an identity for this agent
clientId: "weather-agent"
});When subscribers receive messages, they can use the clientId to determine which agent published the message:
1
2
3
4
5
6
7
8
// Client code
const channel = ably.channels.get("job-map-new");
await channel.subscribe((message) => {
if (message.clientId === "weather-agent") {
console.log("Weather agent response:", message.data);
}
});Adding roles and attributes
Embed custom roles and attributes in messages to enable role-based access control (RBAC) and convey additional context about users and agents. This enables agents to make authorization decisions without additional database lookups.
User claims
Use authenticated claims for users to embed custom claims in JWTs that represent user roles or attributes.
Add claims with names matching the ably.channel.* pattern to your JWT to specify user claims for specific channels. Claims can be scoped to individual channels or to namespaces of channels. The most specific user claim matching the channel is automatically included under extras.userClaim in all messages the client publishes.
Update your getJWTClaims function to specify some user claims:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Server code
// Return the claims payload to embed in the signed JWT.
function getJWTClaims(userId) {
// Returns a payload with `ably.channel.*` claims, which ensures that
// the most specific claim appears as the `message.extras.userClaim`
// on all messages published by the client using this token.
return {
// The user is an editor on all acme channels.
"ably.channel.org:acme:*": "editor",
// The user is a guest on all other channels.
"ably.channel.*": "guest",
};
}When a client authenticates with a JWT containing ably.channel.* claims, Ably automatically includes the most specific matching claim value in the message.extras.userClaim field on messages published by the client:
1
2
3
4
5
6
7
8
9
10
// Agent code
const channel = ably.channels.get("org:acme:job-map-new");
// Subscribe to user prompts
await channel.subscribe("prompt", async (message) => {
// Access the user's role from the user claim in message extras
const role = message.extras?.userClaim;
console.log(`Message from user with role: ${role}`);
});The message.extras.userClaim in the message can be trusted, so agents can rely on this information to make decisions about what actions the user can take. For example, an agent could allow users with an "editor" role to execute tool calls that modify documents, while restricting users with a "guest" role to read-only operations.
Agent metadata
Use message.extras.headers to include custom metadata in agent messages, such as agent roles or attributes.
Agents can directly specify metadata in message.extras.headers. Since agents run as trusted code in server environments, this metadata can be trusted by subscribers. This is useful for communicating agent characteristics, such as which model the agent uses, the agent's role in a multi-agent system, or version information.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Agent code
import * as Ably from "ably";
const ably = new Ably.Realtime({
key: "demokey:*****",
clientId: "weather-agent"
});
const channel = ably.channels.get("job-map-new");
await channel.publish({
name: "update",
data: "It's raining in London",
extras: {
headers: {
model: "gpt-4"
}
}
});Clients and other agents can access this metadata when messages are received:
1
2
3
4
5
6
7
8
9
// Client code
const channel = ably.channels.get("job-map-new");
await channel.subscribe((message) => {
if (message.clientId === "weather-agent") {
const model = message.extras?.headers?.model;
console.log(`Response from weather agent using ${model}:`, message.data);
}
});