PASETO Functions
Platform-Agnostic Security Tokens — the same job as a JWT, without the negotiable algorithm.
The Paseto class is compiled in by default (Cargo feature
paseto). Slim builds can drop it with
--no-default-features and omit paseto
from --features; the class is then not registered.
See Slim binary.
Why PASETO instead of JWT
A JWT announces its own algorithm in a header the bearer controls. That is where
alg: none and the RS256→HS256 confusion attack come from —
jwt_verify
has to defend against it explicitly, by refusing to let the token pick the algorithm.
A PASETO carries its version and purpose as the first two segments of the token:
v4.public.eyJz…. Paseto.verify can only ever
Ed25519-verify a v4.public token. There is nothing to negotiate, so there is
nothing to attack.
| Purpose | Functions | Crypto | Payload |
|---|---|---|---|
v4.local |
Paseto.encrypt / Paseto.decrypt |
XChaCha20 + BLAKE2b, one 32-byte key | Encrypted — the bearer cannot read it |
v4.public |
Paseto.sign / Paseto.verify |
Ed25519 key pair | Signed, readable by anyone |
Reach for local for your own sessions and API tokens, where one service both
mints and reads them. Reach for public when somebody else must validate a token
without holding a secret that would let them mint one.
Only v4 is exposed. v1/v2 are deprecated by the spec, and v3 exists for NIST-algorithm shops — offering a version choice would reintroduce the very agility PASETO set out to remove.
Failures raise, they don't return a hash
Paseto.decrypt and Paseto.verify return the
claims on success and throw on anything else — wrong key, tampering,
expiry, a claim that doesn't match what you expected.
This is deliberately unlike jwt_verify, which returns { "error": true, ... }. That hash is
truthy, so if jwt_verify(...) treats a rejected token as
authenticated. Raising makes the failure path fail closed, and postfix rescue keeps it to one line:
claims = Paseto.verify(token, getenv("PASETO_PUBLIC_KEY")) rescue nil
return render_json({ "error": "unauthorized" }, 401) if claims.nil?
Keys
Keys are PASERK
strings. The prefix names the key's version and purpose, so a local key can never be silently handed to the
signing path. Generate once, store in .env.
Paseto.generate_local_key()
A symmetric key for v4.local, as k4.local.<base64url>.
Paseto.generate_local_key()
# "k4.local.uJq0T2s1…"
Paseto.generate_key_pair()
An Ed25519 key pair for v4.public. Keep secret on the issuer;
hand public to whoever verifies.
Returns
Hash — { "secret": "k4.secret.…", "public": "k4.public.…" }Paseto.public_key(secret_key)
Derives the k4.public. half from a k4.secret. key, so a deployment can store only
the secret. Passing a secret key to Paseto.verify is refused — the signing key has no
business on the verifying side.
Paseto.key_id(key)
The PASERK key ID
of a key — k4.lid.… for a local key, k4.pid.… for a public one. It is a hash of
the key: safe to publish, and exactly what belongs in a token footer as kid when rotating keys.
Requires a PASERK key string; a raw hex key carries no purpose to derive an id from.
Minting tokens
Paseto.encrypt(payload, key, options?)
Mints an encrypted v4.local.… token. Takes the same payload and options as Paseto.sign, documented below.
Parameters
payload : Hash - Claims. Registered names (iss, sub, aud, exp, nbf, iat, jti) go through the spec's typed setters; everything else becomes a custom claim.key : String - A PASERK key string, or raw hex (64 chars for a local key, 128 for a secret key), so openssl rand -hex 32 works.options : Hash? - Optional settingsOptions
expires_in : Int - Seconds from now. Default: 3600 — every token expires unless you say otherwise.exp, nbf, iat : Int | String - An instant, as a Unix timestamp or an RFC 3339 string. exp and expires_in are mutually exclusive; passing both raises rather than silently picking a unit.non_expiring : Bool - Drop exp entirely. The reader must opt in too (see allow_non_expiring).sub, aud, iss, jti : String - Registered claims. PASETO's aud is a single string; there is no array form.footer : Hash - String values that travel unencrypted (even on a local token) but are covered by the authentication tag, so they can be trusted after a successful read and tampering invalidates the token. Values that look like a serialized key are refused.kid : String - Shorthand for { "footer": { "kid": ... } }. Must be a Paseto.key_id(...) value.implicit : String - An implicit assertion: bound into the authentication tag but not carried in the token. The reader must supply the same string or the token fails.Options win over a same-named key in the payload.
key = getenv("PASETO_LOCAL_KEY")
token = Paseto.encrypt({ "user_id": user["_key"], "role": "admin" }, key, { "expires_in": 900 })
# "v4.local.rElw-WywOuwAqKCwObeM…"
Paseto.sign(payload, secret_key, options?)
Mints a signed v4.public.… token with a k4.secret. key. Payload and options are
identical to Paseto.encrypt;
only the key and the resulting header differ.
pair = Paseto.generate_key_pair()
token = Paseto.sign({ "sub": user["_key"] }, pair["secret"], {
"expires_in": 600,
"aud": "api.example.com",
"iss": "https://issuer.example",
"kid": Paseto.key_id(pair["public"])
})
# "v4.public.eyJhdWQiOiJhcGkuZXhhbXBsZS5jb20i…"
Reading tokens
Paseto.decrypt(token, key, options?)
Reads a v4.local token, returning its claims as a Hash.
Raises on a token that isn't authentic, has expired, isn't valid yet,
or fails an expectation you set. Handing it a v4.public token is rejected on the header alone,
before any key is used. Paseto.verify
is the signed-token twin and takes the same options.
Defaults are strict: exp must be present and unexpired, and nbf/iat
must be present and reached. Every relaxation is an explicit opt-in.
Options
audience, issuer, subject, jti : String - Expected values. Setting one makes that claim required, so a token without it cannot slip through a check you believed was enforced.allow_non_expiring : Bool - Accept a token with no exp.skip_valid_at : Bool - Don't check iat/nbf at all. For tokens minted elsewhere that omit them.footer : Hash - Require an exact footer match.implicit : String - The implicit assertion the token was minted with.
exp/nbf/iat come back as the RFC 3339 strings the token carries —
PASETO defines them that way, unlike JWT's numeric dates, and rewriting them here would mean the hash no
longer matches what was signed. They have already been validated by the time you see them.
claims = Paseto.decrypt(token, getenv("PASETO_LOCAL_KEY")) rescue nil
return render_json({ "error": "unauthorized" }, 401) if claims.nil?
user = User.find(claims["user_id"])
Paseto.verify(token, public_key, options?)
Ed25519-verifies a v4.public token with a k4.public. key and returns its claims.
Same options and same raise-on-failure contract as
Paseto.decrypt.
Passing a secret key is refused: the signing key has no business on the verifying side.
# Say who you expect the issuer and the audience to be — otherwise a token
# minted for another service verifies here just fine.
claims = Paseto.verify(token, getenv("PASETO_PUBLIC_KEY"), {
"audience": "api.example.com",
"issuer": "https://issuer.example"
}) rescue nil
return render_json({ "error": "unauthorized" }, 401) if claims.nil?
Paseto.decode_unsafe(token)
Inspects a token without verifying it. Returns
{ "unverified": true, "version": "v4", "purpose": "local" | "public", "claims": Hash | nil, "footer": Hash | String | nil }.
The claims sit behind ["claims"] so code reaching for
result["sub"] gets nil rather than an unauthenticated value. For a
v4.local token claims is always nil — the payload is
ciphertext, which is the point.
This is the intended way to read a footer's kid before choosing a key, which is how
rotation works. The footer is covered by the signature, so the verify that follows still catches a tampered one.
peek = Paseto.decode_unsafe(token)
kid = peek["footer"]["kid"]
# Match the kid against every key still accepted — Paseto.key_id is a hash of
# the key, so this reveals nothing and needs no lookup table.
key = nil
for candidate in [getenv("PASETO_PUBLIC_KEY"), getenv("PASETO_PUBLIC_KEY_PREVIOUS")]
key = candidate if Paseto.key_id(candidate) == kid
end
return render_json({ "error": "unauthorized" }, 401) if key.nil?
claims = Paseto.verify(token, key) rescue nil
return render_json({ "error": "unauthorized" }, 401) if claims.nil?
Common Patterns
API authentication
One service mints and reads the token, so local is the right purpose: the client gets an opaque
string it cannot read, let alone edit.
def create
user = User.find_by("email", params["email"])
return render_json({ "error": "Invalid credentials" }, 401) if user.nil?
return render_json({ "error": "Invalid credentials" }, 401) unless argon2_verify(params["password"], user.password_hash)
token = Paseto.encrypt({ "user_id": user._key, "role": user.role },
getenv("PASETO_LOCAL_KEY"),
{ "expires_in": 86400 })
render_json({ "token": token })
end
def paseto_claims(req)
header = req["headers"]["authorization"] ?? ""
return nil unless header.starts_with("Bearer ")
token = header.substring(7, header.length())
Paseto.decrypt(token, getenv("PASETO_LOCAL_KEY")) rescue nil
end
def current_user(req)
claims = paseto_claims(req)
return nil if claims.nil?
User.find_by("_key", claims["user_id"])
end
Tokens another service verifies
Publish the k4.public. key and the partner can validate tokens forever without ever holding
anything that could mint one. The implicit assertion below pins each token to one tenant without
that tenant id appearing in the token at all.
token = Paseto.sign({ "sub": user._key, "scope": "reports:read" },
getenv("PASETO_SECRET_KEY"),
{
"expires_in": 300,
"iss": "https://issuer.example",
"aud": "reports.partner.example",
"implicit": tenant._key
})
claims = Paseto.verify(token, getenv("ISSUER_PUBLIC_KEY"), {
"issuer": "https://issuer.example",
"audience": "reports.partner.example",
"implicit": tenant._key
}) rescue nil
return forbidden() if claims.nil?