Signing
GET /i/{keyId}/{sig}?...params renders on
first request and serves from cache after that — put it straight in
<meta property="og:image">, no server round trip needed.
sig is an HMAC-SHA256 over the request's own canonical query
string, using your key's signing secret.
The canonical string: drop sig and
debug, sort remaining keys by codepoint, RFC3986-encode each
key=value, join with &.
Every snippet below is a real, interpreter-run file (not hand-copied) and reproduces the
same fixed vector: template=basic&title=Hello World&format=png
signed with secret AAEC...Hh8= →
W8Xvrf9UTFKbVVQtofHq7A2ljK9z-nvJUL3B-rB7MY8.
Node
// docs/snippets/sign.mjs — WP5 step 42 (docs/WP3_WP6_BREAKDOWN.md).
// Builds the canonical query string for a signed `GET /i/{keyId}/{sig}` URL
// and signs it (HMAC-SHA256, base64url). Prints the canonical string, then
// the signature, one per line.
//
// This is the fixed test vector shared with packages/core/src/signing.test.ts
// and tooling/src/snippets.test.ts — running this script must print exactly:
// format=png&template=basic&title=Hello%20World
// W8Xvrf9UTFKbVVQtofHq7A2ljK9z-nvJUL3B-rB7MY8
import { webcrypto } from "node:crypto";
const { subtle } = webcrypto;
// Your key's signing secret, base64 (shown once when the key is issued).
const SIGNING_SECRET_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
const PARAMS = { template: "basic", title: "Hello World", format: "png" };
function base64UrlEncode(bytes) {
return Buffer.from(bytes)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
// RFC3986 encoding: encodeURIComponent leaves !'()* unescaped; escape those too.
function rfc3986Encode(value) {
return encodeURIComponent(value).replace(
/[!'()*]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
}
// Keys excluded from the canonical query (must match packages/core/src/signing.ts).
const CANONICAL_EXCLUDED_KEYS = new Set(["sig", "debug"]);
// Drops `sig`/`debug`, sorts params by key codepoint, RFC3986-encodes each
// "key=value", joins with "&".
function canonicalQuery(params) {
return Object.keys(params)
.filter((key) => !CANONICAL_EXCLUDED_KEYS.has(key))
.sort()
.map((key) => `${rfc3986Encode(key)}=${rfc3986Encode(params[key])}`)
.join("&");
}
async function signQuery(secretBase64, canonical) {
const secret = Buffer.from(secretBase64, "base64");
const key = await subtle.importKey("raw", secret, { name: "HMAC", hash: "SHA-256" }, false, [
"sign",
]);
const mac = await subtle.sign("HMAC", key, new TextEncoder().encode(canonical));
return base64UrlEncode(new Uint8Array(mac));
}
const canonical = canonicalQuery(PARAMS);
console.log(canonical);
console.log(await signQuery(SIGNING_SECRET_BASE64, canonical));
Python
#!/usr/bin/env python3
"""docs/snippets/sign.py — WP5 step 42 (docs/WP3_WP6_BREAKDOWN.md).
Same fixed test vector as sign.mjs; run with `python3 sign.py`, prints:
format=png&template=basic&title=Hello%20World
W8Xvrf9UTFKbVVQtofHq7A2ljK9z-nvJUL3B-rB7MY8
"""
import base64
import hashlib
import hmac
import urllib.parse
# Your key's signing secret, base64 (shown once when the key is issued).
SIGNING_SECRET_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
PARAMS = {"template": "basic", "title": "Hello World", "format": "png"}
def rfc3986_encode(value: str) -> str:
# safe="" — Python's `quote` always leaves unreserved chars (letters,
# digits, "_.-~") alone; nothing else should be left unescaped.
return urllib.parse.quote(value, safe="")
# Keys excluded from the canonical query (must match packages/core/src/signing.ts).
CANONICAL_EXCLUDED_KEYS = {"sig", "debug"}
def _utf16_key(value: str):
# Sorts by UTF-16 code unit, the way JS's default string comparison sorts
# signing.ts's keys — NOT Python's `sorted()`, which compares strings by
# Unicode code point. The two orders agree for BMP-only keys but diverge
# for an astral-plane key (e.g. an emoji, encoded in UTF-16 as a
# surrogate pair starting at 0xD800-0xDBFF) compared against a BMP
# private-use key (e.g. U+E000): UTF-16 order puts the astral key first,
# code point order puts it last. Using plain `sorted()` here would build
# a different canonical string than the server and produce a bad
# signature for such keys.
units = []
for ch in value:
cp = ord(ch)
if cp > 0xFFFF:
cp -= 0x10000
units.append(0xD800 + (cp >> 10))
units.append(0xDC00 + (cp & 0x3FF))
else:
units.append(cp)
return units
def canonical_query(params: dict) -> str:
filtered = {k: v for k, v in params.items() if k not in CANONICAL_EXCLUDED_KEYS}
return "&".join(
f"{rfc3986_encode(k)}={rfc3986_encode(v)}"
for k, v in sorted(filtered.items(), key=lambda kv: _utf16_key(kv[0]))
)
def base64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def sign_query(secret_base64: str, canonical: str) -> str:
secret = base64.b64decode(secret_base64)
mac = hmac.new(secret, canonical.encode(), hashlib.sha256).digest()
return base64url_encode(mac)
if __name__ == "__main__":
canonical = canonical_query(PARAMS)
print(canonical)
print(sign_query(SIGNING_SECRET_BASE64, canonical))
Ruby
#!/usr/bin/env ruby
# docs/snippets/sign.rb — WP5 step 42 (docs/WP3_WP6_BREAKDOWN.md).
# Same fixed test vector as sign.mjs; run with `ruby sign.rb`, prints:
# format=png&template=basic&title=Hello%20World
# W8Xvrf9UTFKbVVQtofHq7A2ljK9z-nvJUL3B-rB7MY8
require "openssl"
require "base64"
require "set"
# Your key's signing secret, base64 (shown once when the key is issued).
SIGNING_SECRET_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
PARAMS = { "template" => "basic", "title" => "Hello World", "format" => "png" }.freeze
UNRESERVED = /[A-Za-z0-9\-_.~]/.freeze
def rfc3986_encode(value)
value.to_s.b.each_byte.map do |byte|
char = byte.chr
UNRESERVED.match?(char) ? char : format("%%%02X", byte)
end.join
end
# Keys excluded from the canonical query (must match packages/core/src/signing.ts).
CANONICAL_EXCLUDED_KEYS = Set["sig", "debug"].freeze
# Sorts by UTF-16 code unit, the way JS's default string comparison sorts
# signing.ts's keys — NOT Ruby's `Array#sort`, which compares strings
# byte-by-byte (UTF-8 byte order). The two orders agree for BMP-only keys but
# diverge for an astral-plane key (e.g. an emoji, encoded in UTF-16 as a
# surrogate pair starting at 0xD800-0xDBFF) compared against a BMP
# private-use key (e.g. U+E000): UTF-16 order puts the astral key first,
# byte order puts it last. Using plain `.sort` here would build a different
# canonical string than the server and produce a bad signature for such keys.
def utf16_key(str)
units = []
str.each_codepoint do |cp|
if cp > 0xFFFF
cp -= 0x10000
units << (0xD800 + (cp >> 10))
units << (0xDC00 + (cp & 0x3FF))
else
units << cp
end
end
units
end
def canonical_query(params)
params.reject { |k, _| CANONICAL_EXCLUDED_KEYS.include?(k) }
.sort_by { |k, _| utf16_key(k) }
.map { |k, v| "#{rfc3986_encode(k)}=#{rfc3986_encode(v)}" }.join("&")
end
def base64url_encode(bytes)
Base64.strict_encode64(bytes).tr("+/", "-_").delete("=")
end
def sign_query(secret_base64, canonical)
secret = Base64.strict_decode64(secret_base64)
mac = OpenSSL::HMAC.digest("SHA256", secret, canonical)
base64url_encode(mac)
end
canonical = canonical_query(PARAMS)
puts canonical
puts sign_query(SIGNING_SECRET_BASE64, canonical)
PHP
<?php
// docs/snippets/sign.php — WP5 step 42 (docs/WP3_WP6_BREAKDOWN.md).
// Same fixed test vector as sign.mjs; run with `php sign.php`, prints:
// format=png&template=basic&title=Hello%20World
// W8Xvrf9UTFKbVVQtofHq7A2ljK9z-nvJUL3B-rB7MY8
//
// Keys are sorted by UTF-16 code unit (see utf16_code_units() below), the way
// JS's default string comparison sorts them in packages/core/src/signing.ts
// — NOT by PHP's native byte order. The two orders agree for ASCII/BMP keys
// but diverge for an astral-plane key (e.g. an emoji) compared against a BMP
// private-use key; using PHP's native ksort()/SORT_STRING there would build
// a different canonical string than the server and produce a bad signature.
// Your key's signing secret, base64 (shown once when the key is issued).
$SIGNING_SECRET_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
$PARAMS = ["template" => "basic", "title" => "Hello World", "format" => "png"];
function rfc3986_encode(string $value): string
{
// rawurlencode() is RFC3986: everything except A-Za-z0-9-_.~ is escaped.
return rawurlencode($value);
}
// Keys excluded from the canonical query (must match packages/core/src/signing.ts).
const CANONICAL_EXCLUDED_KEYS = ["sig", "debug"];
function canonical_query(array $params): string
{
$params = array_diff_key($params, array_flip(CANONICAL_EXCLUDED_KEYS));
uksort($params, "compare_utf16_keys");
$parts = [];
foreach ($params as $key => $value) {
$parts[] = rfc3986_encode($key) . "=" . rfc3986_encode($value);
}
return implode("&", $parts);
}
// Compares two keys by UTF-16 code unit, like JS's default string
// comparison (see the file header comment).
function compare_utf16_keys(string $a, string $b): int
{
$unitsA = utf16_code_units($a);
$unitsB = utf16_code_units($b);
$len = min(count($unitsA), count($unitsB));
for ($i = 0; $i < $len; $i++) {
if ($unitsA[$i] !== $unitsB[$i]) {
return $unitsA[$i] <=> $unitsB[$i];
}
}
return count($unitsA) <=> count($unitsB);
}
// Decodes a UTF-8 string into UTF-16 code units (astral codepoints become a
// surrogate pair), matching how JS represents strings internally.
function utf16_code_units(string $value): array
{
$units = [];
$len = strlen($value);
$i = 0;
while ($i < $len) {
$byte = ord($value[$i]);
if ($byte < 0x80) {
$codepoint = $byte;
$i += 1;
} elseif (($byte & 0xE0) === 0xC0 && $i + 1 < $len) {
$codepoint = (($byte & 0x1F) << 6) | (ord($value[$i + 1]) & 0x3F);
$i += 2;
} elseif (($byte & 0xF0) === 0xE0 && $i + 2 < $len) {
$codepoint = (($byte & 0x0F) << 12)
| ((ord($value[$i + 1]) & 0x3F) << 6)
| (ord($value[$i + 2]) & 0x3F);
$i += 3;
} elseif (($byte & 0xF8) === 0xF0 && $i + 3 < $len) {
$codepoint = (($byte & 0x07) << 18)
| ((ord($value[$i + 1]) & 0x3F) << 12)
| ((ord($value[$i + 2]) & 0x3F) << 6)
| (ord($value[$i + 3]) & 0x3F);
$i += 4;
} else {
$codepoint = $byte;
$i += 1;
}
if ($codepoint > 0xFFFF) {
$codepoint -= 0x10000;
$units[] = 0xD800 + ($codepoint >> 10);
$units[] = 0xDC00 + ($codepoint & 0x3FF);
} else {
$units[] = $codepoint;
}
}
return $units;
}
function base64url_encode(string $bytes): string
{
return rtrim(strtr(base64_encode($bytes), "+/", "-_"), "=");
}
function sign_query(string $secretBase64, string $canonical): string
{
$secret = base64_decode($secretBase64);
$mac = hash_hmac("sha256", $canonical, $secret, true);
return base64url_encode($mac);
}
$canonical = canonical_query($PARAMS);
echo $canonical . "\n";
echo sign_query($SIGNING_SECRET_BASE64, $canonical) . "\n";
Go
// docs/snippets/sign.go — WP5 step 42 (docs/WP3_WP6_BREAKDOWN.md).
// Same fixed test vector as sign.mjs; run with `go run sign.go`, prints:
//
// format=png&template=basic&title=Hello%20World
// W8Xvrf9UTFKbVVQtofHq7A2ljK9z-nvJUL3B-rB7MY8
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"sort"
"strings"
"unicode/utf16"
)
// Your key's signing secret, base64 (shown once when the key is issued).
const signingSecretBase64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
var unreserved = func() [256]bool {
var table [256]bool
for c := 'A'; c <= 'Z'; c++ {
table[c] = true
}
for c := 'a'; c <= 'z'; c++ {
table[c] = true
}
for c := '0'; c <= '9'; c++ {
table[c] = true
}
for _, c := range "-_.~" {
table[c] = true
}
return table
}()
// rfc3986Encode escapes every byte except the RFC3986 unreserved set.
func rfc3986Encode(value string) string {
var b strings.Builder
for i := 0; i < len(value); i++ {
c := value[i]
if unreserved[c] {
b.WriteByte(c)
} else {
fmt.Fprintf(&b, "%%%02X", c)
}
}
return b.String()
}
// canonicalExcludedKeys must match packages/core/src/signing.ts.
var canonicalExcludedKeys = map[string]bool{"sig": true, "debug": true}
// utf16Less reports whether a sorts before b by UTF-16 code unit — the way
// JS's default string comparison sorts signing.ts's keys — NOT Go's
// sort.Strings, which compares strings byte-by-byte (UTF-8 byte order). The
// two orders agree for BMP-only keys but diverge for an astral-plane key
// (e.g. an emoji, encoded in UTF-16 as a surrogate pair starting at
// 0xD800-0xDBFF) compared against a BMP private-use key (e.g. U+E000):
// UTF-16 order puts the astral key first, byte order puts it last. Using
// sort.Strings here would build a different canonical string than the
// server and produce a bad signature for such keys.
func utf16Less(a, b string) bool {
ua := utf16.Encode([]rune(a))
ub := utf16.Encode([]rune(b))
for i := 0; i < len(ua) && i < len(ub); i++ {
if ua[i] != ub[i] {
return ua[i] < ub[i]
}
}
return len(ua) < len(ub)
}
func canonicalQuery(params map[string]string) string {
keys := make([]string, 0, len(params))
for k := range params {
if canonicalExcludedKeys[k] {
continue
}
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return utf16Less(keys[i], keys[j]) })
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, rfc3986Encode(k)+"="+rfc3986Encode(params[k]))
}
return strings.Join(parts, "&")
}
func signQuery(secretBase64, canonical string) (string, error) {
secret, err := base64.StdEncoding.DecodeString(secretBase64)
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(canonical))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
func main() {
params := map[string]string{"template": "basic", "title": "Hello World", "format": "png"}
canonical := canonicalQuery(params)
fmt.Println(canonical)
sig, err := signQuery(signingSecretBase64, canonical)
if err != nil {
panic(err)
}
fmt.Println(sig)
}