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:
PASETO already fixed JWT’s negotiation problem: version and
purpose in the prefix, one cipher suite per version, no
alg header. v4.local is symmetric,
encrypted, and authenticated — the comparison that matters
— and obsigil agrees with it on everything JWT got wrong.
Where they part:
exp as
an RFC 3339 string. Obsigil is integer-keyed canonical
CBOR under a 16-byte SIV tag and no nonce: 2.4×
smaller on the same claims (114 characters against 275 on
the bench’s typical profile, below).exp and a UUIDv7 tid in every
mandate and puts audience checking in the verifier’s
core path.
What PASETO has that obsigil does not: eight years of deployment
and review, a public-key mode (v4.public), and
implementations in far more languages. If you need asymmetric
verification, use PASETO; obsigil is shared-secret only, on
purpose.
Branca is the nearest cousin on the wire — one
XChaCha20-Poly1305 box plus a timestamp, base62 — but it
seals bytes, not claims: no exp, aud, or
sub, no verifier profile. Biscuit and macaroons solve
a different problem (attenuable, delegable capabilities); obsigil
does not attenuate.
Feature for feature, the three shared-secret tokens:
| JWT (HS256) | PASETO (v4.local) | Obsigil | |
|---|---|---|---|
| Payload | signed, public (base64url JSON) | encrypted & authenticated (XChaCha20 + BLAKE2b-MAC) | encrypted & authenticated (AES-SIV / AES-GCM-SIV) |
| Reader split | one payload for everyone | one sealed payload; optional plaintext footer, authenticated under the same key | advisory manifest + enforced mandate |
| Wire form | JSON, JOSE header, 3 dot-parts | JSON, v4.local. prefix, 32-byte nonce + 32-byte
tag |
canonical CBOR, no header, one separator |
| Determinism | not guaranteed | fresh nonce every mint | byte-identical for identical fields (a unique tid
per mandate keeps plaintexts distinct, so
no
plaintext-equality leak) |
| Required claims | none | none (exp checked when present) |
exp and UUIDv7 tid; aud
in the core verify path |
| Keys | shared secret (HS) or key pair (RS/ES) | shared secret (local) or key pair
(public) |
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, as the JWE (dir, A256GCM) you
would need for obsigil’s confidentiality, and as a PASETO
v4.local token, using each language’s dominant
JOSE and PASETO libraries. 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 |
| PASETO (v4.local) | 195 | 275 | 549 |
1.7–2.9× smaller than the JWT family and 1.8–3.4× smaller than PASETO, the gap widest on small tokens — integer-keyed canonical CBOR under one 16-byte tag versus JSON plus framing: 81 characters of JOSE header and signature (JWS and JWE compact come out the same length by construction, the header + IV + tag of one matching the header + signature of the other, and A256GCM being length-preserving), or PASETO’s 95 characters of prefix, 32-byte nonce, and 32-byte tag plus an RFC 3339 expiry. 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) | 834k / 300k | 236k / 130k | 27k / 17k | 35k / 43k |
| JWT (HS256) | 1.11M / 458k | 231k / 152k | 78k / 57k | 17k / 18k |
| JWE (dir, A256GCM) | 468k / 375k | 84k / 66k | 9.5k / 9.0k | 16k / 17k |
| PASETO (v4.local) | 251k / 217k | 129k / 95k | 37k / 23k | 15k / 15k |
Against the capability-equivalent JWE, obsigil wins every cell but one: 2–3× in Go, Python, and TypeScript, and 1.8× on mint in Rust, where josekit’s A256GCM decrypt is the single column JWE takes. Against PASETO, obsigil is 1.4–3.3× faster in Rust, Go, and TypeScript, and slower in Python, where pyseto is 1.3–1.4× ahead. Against plaintext HS256, raw HMAC keeps an edge in Rust and Python, Go is even on mint, 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.