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
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.
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.
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.
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) |
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.
| 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.)
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.
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 |
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.
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.
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.
tid keeps
every mandate distinct and carries the issue time —
no separate iat
exp, audience membership,
and tid well-formedness, then hands back your
clauses; failures collapse to one opaque error
obsigil/obsigil-rs)
obsigil crate
·
docs.rs
obsigil/obsigil-py)
obsigil/obsigil-go)
obsigil.org/go/obsigil
obsigil/obsigil-ts)
@obsigil/server
·
@obsigil/client
·
@obsigil/core
Obsigil
(obsigil/obsigil-perl)
— pure Perl, AES-SIV (code 0) only;
CPAN release pending
Obsigil::Obcrypt
(obsigil/obsigil-obcrypt-perl)
— full-featured overlay: the same token
logic, with all sealing routed through
obcrypt’s
Rust core over FFI — adding AES-GCM-SIV
(code 1) and native-speed cryptography;
CPAN release pending
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.
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.