Guides
Authentication
Every request is signed with ed25519 over a blake2b hash of the method, host, path, expiry, and body. Here is the exact construction, with working code.
Tessera has no sessions, no bearer tokens, and no cookies. Each request is independently signed and independently expires. The scheme is small but unforgiving about details, so this page states them precisely.
The three query parameters
Every authenticated request carries these as query parameters — not headers:
| Parameter | Value |
|---|---|
credential | Your ed25519 public key, base64url encoded with padding. This is also your account identifier. |
validUntil | Unix timestamp in seconds at which the signature expires. You choose it. |
signature | The ed25519 signature, base64url encoded with padding. |
POST /prepare-write?credential=Aq3f…%3D&validUntil=1775212800&signature=9Kd…%3D%3D
Both encodings are padded, and it is checked
credential and signature are decoded with padded base64url. A 32-byte key produces a trailing
=; a 64-byte signature produces a trailing ==. Strip the padding and you get
401 invalid credential. Note also that the parameters must be URL-encoded in the query string,
since = and +-adjacent characters appear in base64url output.
What gets hashed
The signature is over a blake2b-256 digest — not SHA-256 — of five values concatenated with no separators and no length prefixes:
digest = blake2b256(
method // ASCII, e.g. "POST"
‖ host // the HTTP Host header value, e.g. "api.tessera.example"
‖ path // path only, e.g. "/prepare-write"
‖ uint64le(validUntil) // 8 bytes, LITTLE-endian
‖ body // raw request body bytes; omitted entirely if empty
)
signature = ed25519_sign(privateKey, digest)
Then verification is ed25519.Verify(publicKey, digest, signature) — the signature is over the
32-byte digest, not over the message.
Three details that account for most failed integrations:
- The query string is not hashed. Only the path. So
credential,validUntil,signature, and any other query parameter such as?limit=50are outside the signature's protection. validUntilis hashed as a little-endianuint64, while being transmitted in the query string as an ASCII decimal. These are different representations of the same number and both are required.hostmust be byte-identical to theHostheader the server receives — including the port if it is non-default. If you terminate TLS at a proxy that rewritesHost, sign the value the origin sees, not the one you dialled.
TypeScript
import { blake2b } from '@noble/hashes/blake2b'
import { ed25519 } from '@noble/curves/ed25519'
/** base64url WITH padding, as the API requires. */
function b64urlPad(bytes: Uint8Array): string {
let s = ''
for (const b of bytes) s += String.fromCharCode(b)
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_')
}
function b64urlDecode(s: string): Uint8Array {
const b64 = s.replace(/-/g, '+').replace(/_/g, '/')
const pad = b64 + '='.repeat((4 - (b64.length % 4)) % 4)
return Uint8Array.from(atob(pad), (c) => c.charCodeAt(0))
}
export interface Credential {
/** appKey from POST /auth/provision — base64url, unpadded, 64 bytes decoded. */
appKey: string
/** credential from POST /auth/provision — base64url, padded, 32 bytes decoded. */
credential: string
}
export function signRequest(
cred: Credential,
method: string,
host: string,
path: string,
body: Uint8Array | null,
ttlSeconds = 60,
): URLSearchParams {
const validUntil = Math.floor(Date.now() / 1000) + ttlSeconds
const enc = new TextEncoder()
const le = new Uint8Array(8)
new DataView(le.buffer).setBigUint64(0, BigInt(validUntil), true) // little-endian
const parts = [enc.encode(method), enc.encode(host), enc.encode(path), le]
if (body && body.length) parts.push(body)
const total = parts.reduce((n, p) => n + p.length, 0)
const message = new Uint8Array(total)
let off = 0
for (const p of parts) {
message.set(p, off)
off += p.length
}
const digest = blake2b(message, { dkLen: 32 })
// appKey decodes to 64 bytes (seed ‖ public); ed25519 signing takes the 32-byte seed.
const seed = b64urlDecode(cred.appKey).slice(0, 32)
const signature = ed25519.sign(digest, seed)
return new URLSearchParams({
credential: cred.credential,
validUntil: String(validUntil),
signature: b64urlPad(signature),
})
}
Using it:
const base = new URL('https://api.PLACEHOLDER_DOMAIN.example')
const path = '/prepare-write'
const body = new TextEncoder().encode(JSON.stringify({ totalShards: 15, minShards: 10 }))
const params = signRequest(cred, 'POST', base.host, path, body)
const res = await fetch(`${base.origin}${path}?${params}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
})
Note the `host` value
base.host gives api.tessera.example — hostname plus port when present, without the scheme.
That is exactly what the Host header carries and exactly what must be hashed. base.hostname
would drop the port and break signing against a non-default port.
Go
package tessera
import (
"crypto/ed25519"
"encoding/base64"
"encoding/binary"
"net/url"
"strconv"
"time"
"golang.org/x/crypto/blake2b"
)
// SignParams returns the credential, validUntil, and signature query parameters.
func SignParams(appKey, credential, method, host, path string, body []byte, ttl time.Duration) (url.Values, error) {
priv, err := base64.RawURLEncoding.DecodeString(appKey)
if err != nil {
return nil, err
}
validUntil := time.Now().Add(ttl).Unix()
h, err := blake2b.New256(nil)
if err != nil {
return nil, err
}
h.Write([]byte(method))
h.Write([]byte(host))
h.Write([]byte(path))
var le [8]byte
binary.LittleEndian.PutUint64(le[:], uint64(validUntil))
h.Write(le[:])
if len(body) > 0 {
h.Write(body)
}
digest := h.Sum(nil)
sig := ed25519.Sign(ed25519.PrivateKey(priv), digest)
return url.Values{
"credential": {credential},
"validUntil": {strconv.FormatInt(validUntil, 10)},
"signature": {base64.URLEncoding.EncodeToString(sig)},
}, nil
}
If you already depend on go.sia.tech/core, types.NewHasher() produces the identical digest —
it is blake2b-256 with a raw-byte encoder, which is what the construction above reproduces by hand.
Python
import base64, hashlib, struct, time
from nacl.signing import SigningKey
def sign_params(app_key: str, credential: str, method: str, host: str,
path: str, body: bytes = b"", ttl: int = 60) -> dict:
valid_until = int(time.time()) + ttl
priv = base64.urlsafe_b64decode(app_key + "=" * (-len(app_key) % 4))
seed = priv[:32]
h = hashlib.blake2b(digest_size=32)
h.update(method.encode())
h.update(host.encode())
h.update(path.encode())
h.update(struct.pack("<Q", valid_until)) # little-endian uint64
if body:
h.update(body)
sig = SigningKey(seed).sign(h.digest()).signature
return {
"credential": credential,
"validUntil": str(valid_until),
"signature": base64.urlsafe_b64encode(sig).decode(),
}
Choosing an expiry
validUntil is entirely yours to set, and it is the only replay protection in the scheme — there is
no nonce and no server-side record of used signatures. A signed request can be replayed by anyone
who observes it until it expires.
- 60 seconds is a sensible default for interactive calls.
- Do not sign far into the future for convenience. A one-year signature on a
DELETEis a one-year deletion capability sitting in whatever logged the URL. - Sign per request. Because the path and body are covered, a signature is not reusable for a different call anyway.
- Query parameters are not covered, so a signature for
GET /objects?limit=50is equally valid forGET /objects?limit=1000. Treat parameters as unauthenticated.
Verifying a credential
curl -sD - -o /dev/null "$TESSERA_API/auth/check?$AUTH"
# HTTP/1.1 204 No Content → valid and active
# HTTP/1.1 401 Unauthorized → expired, unknown, revoked, or mis-signed
204 means the credential exists, is in the active state, and your signature verified.
Failure modes
Every one of these returns 401 with a plain-text body. The body tells you which:
| Body | Cause |
|---|---|
missing auth params | One of the three query parameters is absent. |
invalid validUntil | Not parseable as a base-10 integer. |
signature expired | validUntil is in the past. Check clock skew before blaming us. |
invalid credential | credential is not valid padded base64url. |
unknown credential | Decoded fine, but is not a provisioned, active key — including revoked keys. |
invalid signature | signature is not valid padded base64url. |
signature mismatch | Verification failed. Usually a wrong host, a hashed query string, big-endian validUntil, or SHA-256 instead of blake2b. |
Debugging `signature mismatch`
Work through it in this order: is host exactly the Host header, including port? Is the path free
of the query string? Is validUntil little-endian in the hash and decimal in the URL? Is the digest
blake2b-256? Is the body byte-identical to what you transmitted — same serialisation, no
re-encoding by an HTTP client in between? That last one catches more people than the rest combined.
Storing credentials
The appKey returned by provisioning is the private key, shown exactly once. It is not
recoverable and we do not have a copy.
- Store it in a secret manager, not in configuration or version control.
- It is the account, so rotating it means provisioning a new credential and re-registering objects under it. There is no way to move existing objects between credentials.
- Revoke compromised credentials immediately with
DELETE /auth/revoke. Revocation takes effect on the next request; there is no token lifetime to wait out.