Stream Signing
Stream signing prevents unauthorized clients from subscribing to arbitrary topics. Topic names are HMAC-SHA256 signed server-side and verified when a WebSocket connection is established. This is analogous to Rails' signed stream names in Action Cable.
How It Works
- Server builds a canonical stream name from identifiers (model IDs, channel names, etc.)
- Server signs the name with HMAC-SHA256 and base64url-encodes it:
base64payload--base64signature - Client receives the signed name (e.g., embedded in HTML) and passes it as a topic query parameter when connecting via WebSocket
- Server verifies the signature before allowing the subscription
Signing a Stream Name
buildTurboStreamName
Builds a canonical colon-separated name from a list of objects:
buildTurboStreamName(['chat', 42]);
// => "chat:42"
buildTurboStreamName(['notifications', user]);
// => "notifications:user-123" (if user.toString() returns "user-123")
Objects implementing TurboStreamIdentifiable use their turboStreamIdentifier property:
class Room implements TurboStreamIdentifiable {
final int id;
Room(this.id);
String get turboStreamIdentifier => 'room-$id';
}
buildTurboStreamName([Room(5), 'messages']);
// => "room-5:messages"
Nested Iterable values are flattened. Null values are skipped. Empty strings are ignored.
signTurboStreamName
Builds the canonical name and returns the signed form:
final signed = signTurboStreamName(['chat', 42]);
// => "Y2hhdDo0Mg==--3xK9f..." (base64url payload + HMAC signature)
verifyTurboStreamName
Verifies a signed name and returns the original topic if valid:
final topic = verifyTurboStreamName(signed);
// => "chat:42" (or null if signature is invalid)
Verification uses constant-time comparison to prevent timing attacks.
Configuring the Signing Secret
By default, a 32-byte random secret is generated at startup. This means signed names are only valid for the lifetime of the process. For production deployments with multiple instances or restarts, set a stable secret:
turboStreamSigningSecret = 'your-stable-secret-key';
Set this before any signing or verification calls. You might load it from environment variables or your application config:
turboStreamSigningSecret = Platform.environment['TURBO_SIGNING_SECRET']
?? 'development-secret';
Generating HTML Source Tags
turboStreamSourceTag generates a <turbo-cable-stream-source> HTML element with a signed stream name. This tag tells the Turbo client to subscribe to the stream:
final tag = turboStreamSourceTag(
streamables: ['chat', roomId],
channel: 'Turbo::StreamsChannel',
dataAttributes: {'controller': 'turbo-stream'},
);
Produces:
<turbo-cable-stream-source
channel="Turbo::StreamsChannel"
signed-stream-name="Y2hhdDox--..."
data-controller="turbo-stream"
></turbo-cable-stream-source>
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
streamables | Iterable<Object?> (required) | — | Objects to build the stream name from |
channel | String | 'Turbo::StreamsChannel' | The channel name attribute |
dataAttributes | Map<String, String>? | null | Extra data-* attributes |
Data attribute keys are normalized: underscores and spaces become hyphens. Values are HTML-escaped.
TurboStreamIdentifiable
Implement this interface on your model classes to provide stable identifiers for stream naming:
abstract class TurboStreamIdentifiable {
String get turboStreamIdentifier;
}
Example:
class User implements TurboStreamIdentifiable {
final int id;
final String name;
User({required this.id, required this.name});
String get turboStreamIdentifier => 'user-$id';
}
class Project implements TurboStreamIdentifiable {
final String slug;
Project({required this.slug});
String get turboStreamIdentifier => slug;
}
// Sign a stream scoped to a user's project
final signed = signTurboStreamName([user, project, 'updates']);
// Canonical name: "user-42:my-project:updates"
Integration with the WebSocket Hub
The default TurboStreamSocketHandler topic resolver automatically calls verifyTurboStreamName on each ?topic= query parameter. If verification succeeds, the original plain-text name is used as the topic. If verification fails (invalid signature), the raw string is used as-is.
This means you can use signed names for secure topics and plain names for public topics in the same application:
// Server: broadcast to the verified topic name
hub.broadcast('chat:42', [fragment]);
// Client connects with a signed topic
// ws://localhost:8080/turbo-streams?topic=Y2hhdDo0Mg==--3xK9f...
// The handler verifies it to "chat:42" and subscribes