Skip to content
Verifying tokens

Verifying tokens

valiss tokens are structurally JWTs but not verifiable with stock JWT libraries: the algorithm identifier and key encoding are valiss-specific. In Go the valiss.dev/valiss package does all of this for you, Decode and the Verify* functions for identity tokens, VerifyMessage for message tokens, VerifySignature for request signatures. This guide is the specification-level recipe underneath those functions. Read it when you verify offline and want to know exactly what is checked, or when you port verification to another language. Everything here is derivable from the Go source, which is canonical if they ever disagree.

Wire format

A token is JWS Compact Serialization: base64url(header) "." base64url(payload) "." base64url(signature), no padding.

The header is byte-exact:

{"typ":"JWT","alg":"ed25519-nkey","ver":1}

ver is the wire-format version. Read it before parsing the payload and reject a version you do not implement; the current version is 1.

The payload is a JSON object with RFC 7519 field names, all optional except valiss, serialized in this order:

fieldmeaning
jticontent-derived token id (see below)
iatissue time, Unix seconds
ississuer public key, nkey-encoded
nameoptional human label of the subject
subsubject public key, nkey-encoded
auddestination identity (message tokens)
expexpiry, Unix seconds; absent = never expires
nbfnot-before, Unix seconds; absent = immediately valid
valissthe level-specific claim body

The valiss section is discriminated by its type field:

  • operator - {type, epoch?, ext?}; self-signed (iss == sub).
  • account - {type, epoch?, ext?}; signed by an operator key.
  • user - {type, epoch?, bearer?, ext?}; signed by an account key.
  • message - {type, epoch?, checksum?, chain?, ext?}; self-signed by a user key. checksum is the lowercase-hex SHA-256 of the message payload. chain is {account, user}: the embedded provenance tokens, verbatim.

ext is an object of named, consumer-defined claim payloads; transport carries them opaquely. The registry of names is open: a verifier that does not recognize an extension name must carry it through untouched rather than reject it, so a port that ignores unknown extensions stays forward-compatible. Fail-closed decoding applies only to names a verifier explicitly opts to check; under those, a present entry that does not match the expected shape rejects the token.

Important

A verifier must carry an extension name it does not recognize through untouched rather than reject it. SPEC-1 keeps the extension registry open, and a port that drops or refuses unknown names breaks forward compatibility with tokens issued against a newer vocabulary. Fail-closed shape checking applies only to the names a verifier explicitly opts to enforce.

Signature

Standard Ed25519 (RFC 8032) over the signing input base64url(header) "." base64url(payload), the ASCII bytes exactly as they appear in the token. The signature part is the raw 64-byte Ed25519 signature, base64url-encoded without padding. Any Ed25519 implementation verifies it once you extract the raw public key from the nkey encoding.

Decoding an nkey public key

iss and sub carry public keys in the NATS nkey text format:

base32( prefix_byte || raw_ed25519_public_key[32] || crc16_le[2] )
  • Base32: RFC 4648 alphabet, uppercase, no padding.
  • Prefix byte encodes the key role: operator 112 (renders as leading O), account 0 (A), user 160 (U).
  • CRC16: CCITT/XMODEM (polynomial 0x1021, init 0x0000), computed over prefix || key, appended little-endian.

To decode: base32-decode, check length 35, verify the trailing CRC16 over the first 33 bytes, check the prefix byte matches the role required by context, take bytes 1..33 as the raw Ed25519 public key.

Role requirements are strict: an operator key signs account tokens, an account key signs user tokens, a user key signs message tokens; operator and message tokens are self-signed (iss == sub).

jti derivation

jti is content-derived, not random: serialize the payload with jti omitted (every field is omit-when-empty), SHA-256 the JSON bytes, and base32-encode the digest (RFC 4648, uppercase, no padding). Reproducing it requires byte-identical JSON: fields in the table’s order, no whitespace, and Go encoding/json escaping rules (which HTML-escape <, >, and &), so re-deriving from a reparsed payload is only possible if your serializer reproduces that exact shape. Two tokens with identical claims issued within the same second therefore share a jti; the allowlist’s registration dedup relies on this. Verifiers that only check signatures may treat jti as opaque.

Verifying a token (one level)

  1. Split on .; require exactly three parts.
  2. Base64url-decode the header; require typ == "JWT", alg == "ed25519-nkey", and ver == 1. Reject an unrecognized ver before parsing the payload.
  3. Base64url-decode the payload; parse JSON.
  4. Decode the iss nkey; verify the Ed25519 signature over part1 "." part2.
  5. Require valiss.type to match the expected level, iss to equal the expected issuer key (established by the level above, or pinned), and sub to be a valid nkey of the level’s subject role.

This establishes authenticity only. Trust and freshness are chain-level checks below. Validity windows: a token is expired when now > exp + skew, not yet valid when now + skew < nbf; absent fields impose nothing. valiss uses a default skew of 2 minutes.

Note

Freshness rests entirely on the verifier’s own clock. There is no issuer to consult, so the skew window is measured against local time, and a verifier with a wrong clock silently accepts expired tokens or rejects valid ones. Keep the verifier’s clock disciplined: it is in the trusted computing base.

Verifying a message token (full chain)

Given a message token, the operator public key(s) you trust, and the received payload bytes:

  1. Verify the message token per above: type == "message", iss == sub, sub is a user-role nkey.
  2. Obtain the chain: valiss.chain embedded in the token, or supplied out of band. If both exist they must match byte-for-byte.
  3. Verify the chain account token: type == "account", signed by a trusted operator key, sub an account-role nkey.
  4. Verify the chain user token: type == "user", iss equal to the account token’s sub, sub a user-role nkey.
  5. Require the chain user token’s sub to equal the message token’s iss: the chain must delegate to exactly the key that signed the message.
  6. Require all three tokens to agree on valiss.epoch (absent = 0). If you hold the domain’s self-signed operator token, additionally require the message epoch to equal its epoch and the operator token to be within its own validity window.
  7. Check all three validity windows at the verification instant. For stored messages, that is the instant of receipt, not now.
  8. If the token carries aud, require it to equal the destination you are verifying for. Reject tokens without aud if you expect one.
  9. If you require payload binding, hash the payload exactly as received and require valiss.checksum to equal its lowercase-hex SHA-256, rejecting a token that carries no checksum. Checksum verification is receiver-opt-in (the Go transports gate it behind WithPayload/RequireChecksum); a receiver that does not ask for it leaves the claim unchecked.

A verified message token proves origin only. It is not a credential: grant nothing for possession of one. Offline receivers hold no allowlist; an online receiver that wants revocation checks the chain account token’s jti against its own allowlist. The allowlist is a predicate over the jti, not a fixed data structure, so a port can back it with a file, a database, or a live policy call; see Allowlist for the shape of that seam.

Verifying a request signature

Per-request proof of possession, separate from tokens. This is the mechanism of the credential transports (contrib/httpauth, contrib/grpcauth); the message-token transports (contrib/httpsig, contrib/grpcsig) carry no request signature, since their binding lives inside the message token (audience and checksum, above).

The client sends a timestamp (RFC 3339 with nanoseconds, UTC) and a signature (base64 standard encoding, unlike the token’s base64url). The signed payload is:

"valiss-req-v1\n" + timestamp + "\n" + lowercase_hex(sha256(request_context))

The valiss-req-v1\n prefix is the version tag, bound into the signed bytes. A verifier that reconstructs v1 bytes fails closed on any other version.

request_context is the transport’s canonical request description:

  • HTTP: "http\n" + method + "\n" + host + "\n" + path + "\n" + nonce
  • gRPC: "grpc\n" + full_method + "\n" + nonce

The nonce is empty when replay suppression is off. Whether to suppress replay at all is left to the implementation, but the behavior is pinned once you do: a signed request must then carry a nonce, a nonce seen before is rejected, and an entry is retained for 2 * skew (the longest a replay could still land inside a valid timestamp window). Verify the Ed25519 signature against the effective subject key (the user token’s sub when a chain is present, else the account token’s sub) and bound the timestamp to a skew window around now.