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:

ParameterValue
credentialYour ed25519 public key, base64url encoded with padding. This is also your account identifier.
validUntilUnix timestamp in seconds at which the signature expires. You choose it.
signatureThe 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:

  1. The query string is not hashed. Only the path. So credential, validUntil, signature, and any other query parameter such as ?limit=50 are outside the signature's protection.
  2. validUntil is hashed as a little-endian uint64, while being transmitted in the query string as an ASCII decimal. These are different representations of the same number and both are required.
  3. host must be byte-identical to the Host header the server receives — including the port if it is non-default. If you terminate TLS at a proxy that rewrites Host, 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 DELETE is 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=50 is equally valid for GET /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:

BodyCause
missing auth paramsOne of the three query parameters is absent.
invalid validUntilNot parseable as a base-10 integer.
signature expiredvalidUntil is in the past. Check clock skew before blaming us.
invalid credentialcredential is not valid padded base64url.
unknown credentialDecoded fine, but is not a provisioned, active key — including revoked keys.
invalid signaturesignature is not valid padded base64url.
signature mismatchVerification 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.