Skip to content

Tokens

PrivacyPass issuance is the identity-bound half of the token lifecycle. Redemption is specified per-operation on the pages that consume tokens; nothing here reappears there, which is the point.

Issuance is blinded. The client generates nonces, blinds them, and sends only the blinded elements. The server evaluates them under its issuance key and returns blinded results, which the client unblinds locally. The server never sees a finished Token - the value it signs is not the value it later verifies.

Tokens are fungible. One token buys one metered action, whichever action that is.

IssuerKey Structure
/// The published issuance key. Rotates on a fixed schedule; the previous
/// key stays valid through an overlap window.
struct IssuerKey {
token_type: u16,
// Server domain, bound into every TokenChallenge as issuer_name
issuer_name: String,
// VOPRF public key, 49 bytes compressed for P-384
token_key: [u8; 49],
// SHA-256 of token key
token_key_id: [u8; 32],
// When this key stops being issued under. Tokens minted under it stay
// spendable for the overlap window past this point.
not_after: u64,
// Seconds the retired key remains redeemable after not_after
overlap_secs: u32,
}

A client needs the current issuance key before it can construct a request. There are two ways to get it, and implementations should support both.

Fetches the current key directly. Use this to pre-warm, so a client’s first metered operation does not have to fail before it can succeed.

  • Input: None
  • Output: IssuerKey
  • Auth: None
  • Idempotent: Yes
  • Errors: None beyond the universal set
  • Servers MUST publish the current issuance public key and its token_key_id
  • The response MUST be cacheable, and clients SHOULD honor not_after
  • Clients SHOULD refresh well inside the overlap window
GET /api/v1/tokens/key

A client that attempts a metered operation without a usable token receives a 401 carrying the key and the challenge, per RFC 9577:

WWW-Authenticate: PrivateToken challenge="<base64url>", token-key="<base64url>"

This is the recovery path, not the normal one. A client relying on it spends a failed round trip per key rotation.

  • Servers MUST include this header on TOKEN_MISSING, TOKEN_INVALID, and TOKEN_KEY_UNKNOWN

Each server has exactly one challenge, and every token it issues is minted against it:

/// From RFC 9577
TokenChallenge {
token_type: 0x0001,
issuer_name: <server domain, from IssuerKey.issuer_name>,
redemption_context: <empty>,
origin_info: <empty>,
}

challenge_digest is its SHA-256, carried in every token. It binds a token to the server that minted it and nothing more.

  • Clients MUST construct the challenge from IssuerKey.issuer_name
  • Servers MUST reject a token whose challenge_digest does not match their own

Exchanges a blinded batch for blinded evaluations.

TokenRequest Structure
/// A blinded issuance request, per RFC 9578. The server evaluates this
/// without learning the nonce inside it.
struct TokenRequest {
// 0x0001. Servers MUST reject any other value.
token_type: u16,
// Least significant byte of the issuance key's token_key_id. Lets the
// server pick the right key without the client sending all 32 bytes.
truncated_token_key_id: u8,
// Blinded element. 49 bytes is the compressed point length for P-384.
blinded_msg: [u8; 49],
}
/// Clients batch so that issuance frequency is not itself a signal.
struct TokenRequestBatch {
requests: Vec<TokenRequest>,
}
  • Input: TokenRequestBatch
  • Output: TokenResponseBatch
  • Auth: Device authentication
  • Idempotent: No
  • Errors: DEVICE_NOT_ANNOUNCED, DEVICE_BLOCKED, BUDGET_EXHAUSTED, TOKEN_TYPE_UNSUPPORTED, TOKEN_KEY_UNKNOWN
  • Devices MUST authenticate; servers MUST reject a device with no current record
  • Servers MUST reject any token_type other than 0x0001
  • Servers MUST enforce the device’s Trust Tier bucket at issuance
  • Servers MUST grant partial batches rather than rejecting when a bucket is short
  • Servers MUST return BUDGET_EXHAUSTED only when zero were available
  • Servers MUST NOT record which tokens were issued to which device beyond a count
  • Servers MUST NOT consult bucket state at redemption
  • Clients MUST batch, and SHOULD request replacements well before exhausting their supply

responses MAY be shorter than requests. That is a successful response, not an error, and entries correspond to the first N requests in order.

bucket_level and bucker_rate accompany every grant so a client can schedule its next top-up without probing.

POST /api/v1/tokens/issue
Authorization: <device authentication>
  • Issuance keys MUST rotate on a fixed schedule, defaulting to 30 days
  • Servers MUST continue accepting tokens under the previous key for a 7-day overlap
  • The spent-nonce set for a retired key MAY be discarded once its overlap closes
  • A token is spendable for at most rotation + overlap from issuance

A client holding tokens minted under a retired key receives TOKEN_KEY_UNKNOWN. The recovery is to re-fetch the issuer key and request a fresh batch; retrying the old token cannot succeed.

The server learns at issuance The server learns at redemption
Which device asked That a valid token was spent
How many it granted Nothing else
Roughly when

Nothing joins the two columns, and every token looks like every other. Tokens are also server-local i.e., they redeem only at the issuing server, and servers MUST reject tokens minted under another server’s key.