Obsigil

Sealed mandate tokens — a shared-secret, encrypted alternative to JWT

Obsigil is a mandate-token format — a single token string split into a public, advisory manifest (display-only claims anyone can read) and a secret-sealed, authoritative mandate (binding clauses the backend enforces). Each half is an authenticated, deterministically-encrypted ciphertext (AES-SIV or AES-GCM-SIV) over a canonical-CBOR field map, so the same fields always mint byte-identical tokens and there is no nonce to manage. Verification is symmetric — one key both mints and verifies — making obsigil a drop-in for the shared-secret (HS256-style) JWT and JWE use cases, without a signature, a JOSE header, or a JSON wire form.

-WhixIj8T6kxljCMVsmY0OGOSZh68pQe8a6U9ZuRBjqSnUN96lSHeRFa0.03MK_shWrguB4IXqoTAftVxrdTTvjTNSCRWmActcPDHf__V6pRHvv-O-6wb2PfgOL0W2lkzCYZr-1AoE_1Vi2cs9gFNy1kzI

manifest — public, advisory; anyone can open it for display  ·  mandate — sealed, authoritative; only the key holder can read or verify it  ·  the separator carries the text encoding, a one-character algorithm code flanks each half

Encrypted by default

A JWT's claims are world-readable base64. An obsigil half is AEAD ciphertext — confidentiality isn't an upgrade path (JWE), it's the format. There is no unencrypted mode to misconfigure your way into.

Smaller on the wire

Integer-keyed canonical CBOR under a single AEAD tag, not twice-base64’d JSON plus an HMAC — and far smaller than the JWE you would need for the same confidentiality. Measured below.

The common case, nailed

No negotiable header, no algorithm registry, required expiry and token id, one opaque failure. What JWT makes possible-but-dangerous, obsigil makes impossible-or-default.

Why not JWT?

JWT is a framework; obsigil is a decision. A JWT deployment chooses an algorithm out of an open registry, a key infrastructure, a claims discipline, and a validation posture — and then renegotiates several of those choices at verify time through an attacker-supplied header. The classic JWT failures are not implementation accidents; they are the generality doing what it was designed to do: alg: none, RS/HS key confusion, embedded JWKs, tokens accepted by the wrong audience, and claims that everyone can read but that developers keep treating as private.

Meanwhile the deployment that dominates practice is unglamorous: one backend — or a small family of services — minting short-lived bearer tokens and verifying them with a shared secret. HS256, exp, aud, sub, a few application claims. JWT serves that case the way a framework serves every case: adequately, with footguns.

Obsigil identifies that case and nails it:

Feature for feature against the shared-secret JWT family:

JWT (HS256) Obsigil
Payload signed, public (base64url JSON) encrypted & authenticated (AEAD)
Reader split one payload for everyone advisory manifest + enforced mandate
Wire form JSON, JOSE header, 3 dot-parts canonical CBOR, no header, one separator
Determinism not guaranteed byte-identical for identical fields (a unique tid per mandate keeps plaintexts distinct, so no plaintext-equality leak)
Keys shared secret (HS) or key pair (RS/ES) shared secret only (symmetric)

The numbers

Measured, not asserted: the obsigil-bench harness mints the same claims — sub, exp, aud, a token id, a scope — as an obsigil token, as the HS256 JWT everyone deploys, and as the JWE (dir, A256GCM) you would need for obsigil's confidentiality, using each language's dominant JOSE library. Every number below is reproducible from the pinned protocol in that repo.

Token size

Characters on the wire minimal typical heavy
Obsigil (AES-SIV, b64) 57 114 312
JWT (HS256) 165 248 523
JWE (dir, A256GCM) 165 248 523

2.2–2.9× smaller at every profile — integer-keyed canonical CBOR under one AEAD tag versus twice-base64’d JSON plus 81 characters of JOSE framing. (JWS and JWE compact come out the same length by construction: the header + signature overhead of one equals the header + IV + tag overhead of the other, and A256GCM ciphertext is length-preserving. Encrypting with JOSE costs you throughput, not size — you were already paying the size.)

Throughput

mint / verify, operations per second, single thread, typical profile (i5-1135G7)

Rust Go Python TypeScript
Obsigil (AES-SIV) 638k / 279k 155k / 80k 24k / 15k 32k / 37k
JWT (HS256) 1.00M / 422k 137k / 93k 68k / 48k 6.8k / 7.4k
JWE (dir, A256GCM) 411k / 313k 52k / 41k 8.2k / 7.7k 6.0k / 7.6k

Read it honestly: against the capability-equivalent JWE, obsigil wins nearly every cell — up to 3× in Go and Python, 5× in TypeScript. Against plaintext HS256, raw HMAC speed keeps an edge in Rust and Python, obsigil out-mints it in Go, and in TypeScript obsigil sweeps every column — but that comparison trades away confidentiality: HS256’s speed buys world-readable claims. Either way the floor is tens of thousands of tokens per second per core in scripting languages and hundreds of thousands in compiled ones — token handling will not be your bottleneck.

Specification

Version 1.0 is stable. Available in HTML and PDF.

Document Description Download
Mandate-Token Format
version 1.0
wire format, the two algorithms, reserved fields, security model, and a cross-language API conformance profile HTML · PDF

Quick Start

Rust is the reference implementation; Go, Python, TypeScript, and Perl implementations are also available, all validated against the same cross-language test vectors.

Install:

cargo add obsigil               # AES-SIV (code 0); add --features gcm-siv for AES-GCM-SIV (code 1)
cargo add serde --features derive
pip install obsigil
go get obsigil.org/go/obsigil
pnpm add @obsigil/server @obsigil/client    # or npm install
cpanm https://gitlab.com/obsigil/obsigil-perl.git    # pure Perl, AES-SIV (code 0); CPAN release pending
# For AES-GCM-SIV (code 1) and native-speed sealing, use the
# Obsigil::Obcrypt overlay (obcrypt's Rust core over FFI) instead:
cpanm https://gitlab.com/obsigil/obsigil-obcrypt-perl.git

Generate a 64-byte mandate key — the same bytes mint and verify, so provision them to issuer and verifier alike:

// Fresh key from the OS CSPRNG, as 128 lowercase hex digits —
// the form to store as a secret:
let key = obsigil::generate_key();        // then MandateKey::from_hex(&key)?

// Or wrap raw bytes you loaded from secure storage:
let key = MandateKey::from_bytes([42u8; 64])?;
import obsigil

# Fresh key from the OS CSPRNG, as 128 lowercase hex digits — the
# form to store as a secret. mint/clauses accept hex or 64 raw bytes;
# the example below pins demo bytes instead.
key = obsigil.generate_key()
// Fresh key from the OS CSPRNG, as 128 lowercase hex digits — the
// form to store as a secret. The example below pins demo bytes via
// the raw-byte alternative fields instead.
key, err := obsigil.GenerateKey() // (string, error)
import { generateKey } from "@obsigil/server";

// Fresh key from the OS CSPRNG, as 128 lowercase hex digits — the
// form to store as a secret. mint/clauses accept hex or a
// Uint8Array(64); the example below pins demo bytes instead.
const key = generateKey();
use Obsigil qw(generate_key);

# Fresh key from the OS CSPRNG, as 128 lowercase hex digits — the
# form to store as a secret. mint/clauses accept hex or 64 raw bytes;
# the example below pins demo bytes instead.
my $key = generate_key();

Mint a token, then read it from both sides — the front end opens the public manifest with no secret, the backend authenticates the mandate and gets typed clauses back:

use obsigil::{claims, Claims, Clauses, Issuer, MandateKey, Verifier};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct ClauseData { scope: String }   // authoritative mandate clauses (app data)

#[derive(Serialize, Deserialize)]
struct ClaimData { name: String }  // advisory manifest claims (app data)

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // The 64-byte shared secret, provisioned to issuer and verifier
    // alike. In production create it once with `obsigil::generate_key()`
    // and load the same bytes on each side; pinned here for a
    // reproducible example.
    let secret = [42u8; 64];

    // --- Issuer: mint a token (AES-SIV + base64 are the defaults) ---
    let token = Issuer::new(MandateKey::from_bytes(secret)?)
        .clauses(&ClauseData { scope: "read:invoices".into() }) // sealed, binding
        .exp(4_000_000_000)                  // REQUIRED absolute expiry
        .subject("user-42")                  // optional sub clause
        .audience(["invoice-api"])           // optional aud clause
        .manifest("auth.example",  // public advisory half
                  &ClaimData { name: "Ada".into() })
        .mint()?;                            // tid: fresh UUIDv7
    println!("{token}");

    // --- Front end: read the public manifest, no secret (advisory) ---
    let advisory: Claims<ClaimData> = claims(&token).expect("manifest present");
    assert_eq!(advisory.issuer(), "auth.example");
    assert_eq!(advisory.app().name, "Ada");

    // --- Backend: authenticate the mandate and read its clauses ---
    let verify_key = MandateKey::from_bytes(secret)?;
    let clauses: Clauses<ClauseData> = Verifier::new()
        .key(&verify_key)
        .audience("invoice-api")
        .now(1_000_000_000) // pin "now"; omit to read the system clock
        .clauses(&token)?;  // checks exp, aud, UUIDv7 tid; opaque Error on failure

    assert_eq!(clauses.subject(), Some("user-42"));
    println!("scope = {}", clauses.app().scope); // -> scope = read:invoices
    Ok(())
}
import obsigil
from obsigil import Obsigil

secret = bytes([42] * 64)

# --- Issuer: mint a token (AES-SIV + base64 are the defaults) ---
token = Obsigil.mint(
    clauses={"scope": "read:invoices"},      # sealed, binding mandate clauses
    mandate_key=secret,
    exp=4_000_000_000,                       # REQUIRED absolute expiry
    sub="user-42",                           # optional sub clause
    aud=["invoice-api"],                     # optional aud clause
    manifest={                               # public advisory half
        "iss": "auth.example",     # manifest iss is REQUIRED
        "claims": {"name": "Ada"},
    },
).token()                                    # tid: fresh UUIDv7
print(token)

# --- Front end: read the public manifest, no secret (advisory only) ---
advisory = obsigil.claims(token)             # dict | None — never raises
assert advisory is not None                  # manifest present
assert advisory["iss"] == "auth.example"
assert advisory["name"] == "Ada"

# --- Backend: authenticate the mandate and read its clauses ---
verifier = Obsigil(
    token,
    keys=secret,
    audience="invoice-api",
    now=1_000_000_000,                       # pin "now"; omit to read the system clock
)
clauses = verifier.clauses()                 # checks exp, aud, UUIDv7 tid; one opaque ObsigilError
assert clauses["sub"] == "user-42"
print(f"scope = {clauses['scope']}")         # -> scope = read:invoices
import (
	"fmt"
	"log"

	"obsigil.org/go/obsigil"
)

func main() {
	secret := make([]byte, 64)
	for i := range secret {
		secret[i] = 42
	}

	// --- Issuer: mint a token (AES-SIV + base64 are the defaults) ---
	token, err := obsigil.Mint(obsigil.MintInput{
		Clauses:         map[string]any{"scope": "read:invoices"}, // sealed, binding
		MandateKeyBytes: secret,                 // or MandateKey: "…128 hex digits…"
		Exp:             4_000_000_000,           // REQUIRED absolute expiry
		Sub:             "user-42",               // optional sub clause
		Aud:             []string{"invoice-api"}, // optional aud clause
		Manifest: &obsigil.ManifestSpec{ // public advisory half
			Iss:    "auth.example",
			Claims: map[string]any{"name": "Ada"},
		},
		// Tid defaults to a fresh UUIDv7.
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(token)

	// --- Front end: read the public manifest, no secret (advisory only) ---
	advisory, ok := obsigil.Claims(token)
	if !ok {
		log.Fatal("manifest present")
	}
	if iss, _ := obsigil.Iss(advisory); iss != "auth.example" {
		log.Fatalf("issuer = %q", iss)
	}
	_ = advisory["name"] // advisory app claim, "Ada"

	// --- Backend: authenticate the mandate and read its clauses ---
	clauses, err := obsigil.Clauses(token, obsigil.VerifyPolicy{
		KeysBytes: [][]byte{secret}, // or Keys: []string{"…hex…"}
		Audience:  "invoice-api",
		Now:       1_000_000_000, // pin "now"; 0 reads the system clock
	}) // checks exp, aud, UUIDv7 tid; one opaque *Error on failure
	if err != nil {
		log.Fatal(err)
	}

	if sub, _ := obsigil.Sub(clauses); sub != "user-42" {
		log.Fatalf("subject = %q", sub)
	}
	fmt.Printf("scope = %s\n", clauses["scope"]) // -> scope = read:invoices
}
import { mint, clauses } from "@obsigil/server";
import { claims } from "@obsigil/client";

const secret = new Uint8Array(64).fill(42);

// --- Issuer: mint a token (AES-SIV + base64 are the defaults) ---
const token = mint({
  mandateKey: secret,
  clauses: { scope: "read:invoices" }, // sealed, binding application clauses
  exp: 4_000_000_000,                  // REQUIRED absolute expiry
  sub: "user-42",                      // optional sub clause
  aud: ["invoice-api"],                // optional aud clause
  manifest: {                          // public advisory half
    iss: "auth.example",
    claims: { name: "Ada" },
  },
}); // tid: a fresh UUIDv7 unless you pass one
console.log(token);

// --- Front end: read the public manifest, no secret (advisory only) ---
const advisory = claims(token); // Claims | null — null means "display nothing"
if (advisory === null) throw new Error("manifest present");
if (advisory.iss !== "auth.example") throw new Error("bad issuer");
if (advisory.name !== "Ada") throw new Error("bad name");

// --- Backend: authenticate the mandate and read its clauses ---
const verified = clauses(token, {
  keys: secret,            // same key mints and verifies (symmetric)
  audience: "invoice-api",
  now: 1_000_000_000,      // pin "now"; omit to read the system clock
}); // checks exp, aud, UUIDv7 tid; one opaque ObsigilError on failure

if (verified.sub !== "user-42") throw new Error("bad subject");
console.log(`scope = ${verified.scope}`); // -> scope = read:invoices
use strict;
use warnings;
use Obsigil qw(mint claims clauses);

my $secret = "\x2a" x 64;

# --- Issuer: mint a token (AES-SIV + base64 are the defaults) ---
my $token = mint(
    mandate_key => $secret,
    clauses     => { scope => 'read:invoices' },    # sealed, binding
    exp         => 4_000_000_000,                   # REQUIRED absolute expiry
    sub         => 'user-42',                       # optional sub clause
    aud         => ['invoice-api'],                 # optional aud clause
    manifest    => {                                # public advisory half
        iss    => 'auth.example',
        claims => { name => 'Ada' },
    },
);                                                  # tid: fresh UUIDv7
print "$token\n";

# --- Front end: read the public manifest, no secret (advisory only) ---
my $advisory = claims($token) or die "manifest present\n";  # hashref or undef
die "issuer\n" unless $advisory->{iss} eq 'auth.example';
die "name\n"   unless $advisory->{name} eq 'Ada';

# --- Backend: authenticate the mandate and read its clauses ---
my $clauses = clauses(
    $token,
    keys     => $secret,
    audience => 'invoice-api',
    now      => 1_000_000_000,   # pin "now"; omit to read the system clock
);                               # checks exp, aud, UUIDv7 tid; dies opaquely on failure

die "subject\n" unless $clauses->{sub} eq 'user-42';
print "scope = $clauses->{scope}\n";    # -> scope = read:invoices

The token's text encoding is base64 by default. To emit a hex token instead — lowercase base16, joined by the ~ separator — change one line on the issuer; the verifier needs no change, since it recovers the encoding from the separator:

use obsigil::Encoding;

let token = Issuer::new(MandateKey::from_bytes(secret)?)
    .encoding(Encoding::Hex)             // "~" separator instead of "."
    .clauses(&ClauseData { scope: "read:invoices".into() })
    .exp(4_000_000_000)
    .subject("user-42")
    .audience(["invoice-api"])
    .manifest("auth.example", &ClaimData { name: "Ada".into() })
    .mint()?;
// e.g. 2b8c53c5fe30...6e930~0e97f7b68e...f7ee7  (verify exactly as before)

Hex is strictly lowercase (0-9a-f), so case-folding a hex token is lossless: it survives case-insensitive channels — DNS labels, case-normalizing URLs — where base64's significant case would corrupt it. The canonical form is lowercase, and a verifier rejects mixed-case input unless a deployment lowercases it first.

The token

A token is the two halves joined by a single separator that names the text encoding, with a one-character algorithm code on each side naming that half's cipher:

token = [ manifest ALG ] SEP [ ALG mandate ]
SEP   = "." / "~"     ; "." = b64, "~" = hex
ALG   = "0" / "1"     ; 0 = AES-SIV, 1 = AES-GCM-SIV

So a complete b64 token, both halves AES-SIV, looks like this — manifest, 0.0, mandate:

-WhixIj8T6kxljCMVsmY0OGOSZh68pQe8a6U9ZuRBjqSnUN96lSHeRFa0.03MK_shWrguB4IXqoTAftVxrdTTvjTNSCRWmActcPDHf__V6pRHvv-O-6wb2PfgOL0W2lkzCYZr-1AoE_1Vi2cs9gFNy1kzI

The brackets in the grammar are real: either half may be empty, and an empty half is absent — it carries no algorithm code. Three shapes are therefore well-formed:

manifest0.0mandate   ; the full token
manifest0.           ; manifest-only: advisory claims, nothing enforceable
.0mandate            ; mandate-only: all the authority, no display

Each degenerate shape is a standalone, well-formed token in its own right — and because the separator trails a manifest-only token but leads a mandate-only one, the two are structurally distinct: a parser never confuses manifest0. with a mandate that lost its half.

Both halves default to AES-SIV (algorithm code 0) — required in every implementation, and the best performer for small and standard-sized tokens. AES-GCM-SIV (code 1) is optional — usable where both sides support it — and scales better with input size, outperforming AES-SIV on very large tokens. The separator carries the text encoding (. b64, ~ hex) independently of either half's cipher.

Transport

A token is a bearer credential carried in transport metadata — an Authorization value, a cookie, a message field. The canonical topology: the front end holds the full token, opens the manifest for display, and forwards only the mandate (.0mandate) to the backend, which decrypts and enforces it. The keyless accessors manifest(token) and mandate(token) carve either half out as a standalone token — no key, no decryption, a pure string transform — so the forwarded value is simply mandate(token), and the authorization_header helper wraps it as a ready Bearer value.

For a browser client, the same split is typically delivered as two cookies, set side by side: the mandate-only token in an HttpOnly cookie — the browser attaches the credential to every backend request, scripts can never read it, so XSS cannot exfiltrate it — and the manifest-only token in a plain cookie that client-side code reads for display:

Set-Cookie: __Host-mandate=.03MK_shWrgu...Ny1kzI; Path=/; Secure; HttpOnly; SameSite=Strict
Set-Cookie: manifest=-WhixIj8T6kx...HeRFa0.; Path=/; Secure

The two halves are not cryptographically bound — and don't need to be, because the manifest is advisory and a reader MUST NOT trust its claims. All enforcement rests on the mandate, so splitting a token loses nothing. The separators were picked for exactly these channels: . and ~ pass both the Authorization token68 grammar and the cookie-octet alphabet, so either half travels as-is — no re-encoding, no quoting.

Features

Implementations

Conformance

Obsigil is specified once and verified everywhere: every implementation checks itself against the same language-agnostic test vectors — deterministic sealing means each vector pins the exact token bytes a conformant implementation must produce, and the inputs every implementation must reject.

License

The Obsigil specification and the documentation on this website are licensed under CC BY 4.0; the reference implementation is dual-licensed under the MIT or Apache 2.0 licenses, at your option.