Legacy compatibility · sd1

One payload. One purpose. Legacy sd1.

This guide covers the legacy JSON/Base64 sd1 compatibility profile in Wasm, JavaScript, and Ruby. New raw-byte integrations should start with SDB1.

Not the SDB1 onboarding pathThe examples below intentionally produce sd1.<base64url header>.<base64url payload>.<base64url signature>. They do not demonstrate SDB1's binary no-transcode path.

1. Keep three decisions separate

  1. Your application decides which bytes matter.
  2. Your signer owns algorithm, key identity, and signature operations.
  3. 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
Demo key modelA shared secret delivered to public browser JavaScript is visible to that browser's user. The HMAC examples teach interoperability. Public clients should normally verify an asymmetric signer supplied by application code.

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.mjs proves Wasm/Ruby token equality.
  • test/browser/test_streaming.mjs shows practical chunk loops.
  • test/test_parcel_stream_streaming.rb tells the same story in Ruby.
  • test/test_usage_examples.rb covers 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 peek and 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.

Continue with practical integration patterns →