1. Keep three decisions separate
- Your application decides which bytes matter.
- Your signer owns algorithm, key identity, and signature operations.
- SignedData authenticates the exact envelope and applies purpose/expiry policy.
sd1.<base64url header>.<base64url payload>.<base64url signature>
Text, JSON, ParcelStream, images, and application records are all just payload bytes to the envelope.
2. Start with Wasm + JavaScript
Copy signed-data.mjs, signed-data-wasm.mjs, and signed-data-core.wasm from docs/ to one static directory. Then serve that directory over HTTP—module imports and Wasm loading do not work reliably from a double-clicked file: page.
import { sign, utf8, utf8Text, verify }
from "./signed-data.mjs";
import { createWasmHmacSigner, loadSignedDataWasm }
from "./signed-data-wasm.mjs";
const core = await loadSignedDataWasm();
const signer = createWasmHmacSigner(
core,
"local demo secret",
{ keyId: "primary-2026-08" }
);
const token = await sign(utf8("hello"), {
signer,
purpose: "example/message/1"
});
const envelope = await verify(token, {
signer,
purpose: "example/message/1"
});
utf8Text(envelope.payload); // "hello"
For a local checkout:
npm ci
npm run build:wasm
node script/serve-docs.mjs
# open http://127.0.0.1:8000
3. Use the Ruby gem as a peer implementation
Until a gem release is published, use a local checkout or pinned commit in development. Do not deploy an unpinned moving branch.
# Gemfile
gem "signed_data", path: "../signed_data"
require "signed_data"
signer = SignedData::HMACSigner.new(
ENV.fetch("SIGNED_DATA_SECRET"),
key_id: "primary-2026-08"
)
token = SignedData.sign(
"hello".b,
signer: signer,
purpose: "example/message/1"
)
envelope = SignedData.verify(
token,
signer: signer,
purpose: "example/message/1"
)
envelope.payload # => "hello"
envelope.verified? # => true
Ruby uses binary String values; JavaScript uses Uint8Array. Conversion is explicit, while the wire bytes stay identical.
4. Bind a token to its job
Purpose prevents a valid token created for one action from being replayed in another. Expiry limits how long the signature grants meaning.
const now = Math.floor(Date.now() / 1000);
const token = await sign(payload, {
signer,
purpose: "account/contact-form/1",
issuedAt: now,
expiresAt: now + 300
});
await verify(token, {
signer,
purpose: "account/contact-form/1"
});
Ruby spells the timestamp keys issued_at and expires_at. Both encode integer Unix seconds into the same header names.
5. Make bytes optionally self-describing
description: {
content_type: "application/json",
schema: "example/contact/1",
language: "en"
}
The description is signed JSON context, not a magic decoder. It may help humans, routers, archives, and applications understand the bytes after verification.
const hint = peek(token);
hint.description; // readable, attacker-controlled
hint.verified; // false
const trusted = await verify(token, { signer });
trusted.description; // authenticated now
6. Add ParcelStream only when structure helps
import {
deserialize,
parcelStreamSerializer,
signSerialized,
utf8,
verify
} from "./signed-data.mjs";
const token = await signSerialized(
[[1, utf8("alice")], [2, utf8("hello")]],
{
signer,
serializer: parcelStreamSerializer,
purpose: "example/message/1",
digest: (bytes) => core.sha256(bytes)
}
);
const envelope = await verify(token, {
signer,
purpose: "example/message/1"
});
const parcels = await deserialize(envelope, {
serializer: parcelStreamSerializer,
digest: (bytes) => core.sha256(bytes)
});
The serializer ID and SHA-256 content identity are authenticated. The caller still selects the serializer from trusted code. For incremental reads, continue to the partial-stream guide and simulator.
7. Open the complete workspace
The repository offers Alpine 3.23 and Ubuntu 24.04 dev-container choices. Both install Ruby, Node, Bundler, and npm; rebuild the Wasm artifact; run every runtime/site test; and forward port 8000.
npm ci
npm run build:wasm
bundle install
bundle exec rake test:all
bundle exec rake site
Read the tests as examples:
test/browser/test_wasm.mjsproves Wasm/Ruby token equality.test/browser/test_streaming.mjsshows practical chunk loops.test/test_parcel_stream_streaming.rbtells the same story in Ruby.test/test_usage_examples.rbcovers the gem's main API.
8. Production checklist
- Keep private/signing keys outside source and rotate them under explicit IDs.
- Require a purpose wherever cross-context replay matters.
- Use short expiries for action-granting tokens and add replay storage for once-only behavior.
- Treat
peekand incremental framing as untrusted until an authentication boundary succeeds. - Select signers and serializers from trusted configuration, never from incoming names alone.
- Set application-appropriate maximum payload and ParcelStream value sizes.
- Add encryption separately when confidentiality matters.