Sealed mandate tokens — a shared-secret, encrypted alternative to JWT
Mandate-Token Format Specification version 1.0 draft
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.
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
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.
The front end opens the manifest for display; the backend
receives the mandate (.0mandate) and enforces it. 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.
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
The 1.0 draft is settled. Available in HTML and PDF.
| Document | Description | Download |
|---|---|---|
| Mandate-Token Format
version 1.0 draft |
wire format, the two algorithms, reserved fields, security model, and a cross-language API conformance profile | HTML · PDF |
Rust is the reference implementation. Go and Python are also published; TypeScript and Perl implementations are in progress and will be listed here as each is published.
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
Generate a 64-byte mandate key — the same bytes mint and verify, so provision them to issuer and verifier alike:
// Fresh 64-byte key from the OS CSPRNG (zeroized on drop):
let key = obsigil::generate_key(); // or MandateKey::generate()
// Or wrap bytes you loaded from secure storage:
let key = MandateKey::from_bytes([42u8; 64])?;
import obsigil
# Fresh 64-byte key from the OS CSPRNG; provision the same
# bytes to issuer and verifier. The example below pins demo
# bytes instead.
key = obsigil.generate_key()
// 64-byte mandate key from the OS CSPRNG; provision the same bytes to
// issuer and verifier. The example below pins demo bytes instead.
key, err := obsigil.GenerateKey() // ([]byte, error), len 64
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
MandateKey: secret,
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{
Keys: [][]byte{secret},
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
}
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.
Obsigil serves the same shared-secret, bearer-credential niche as a signed JWT, but seals rather than signs — the payload is encrypted, not merely encoded.
| 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) |
obsigil/obsigil-rs)
obsigil crate
·
docs.rs
obsigil/obsigil-py)
obsigil/obsigil-go)
obsigil.org/go/obsigil
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.