AuraImage

Signature Spec

Normative HMAC-SHA256 token format. Use this to implement signing in any language.

@auraimage/sdk is a TypeScript implementation. The wire format is small and language-agnostic — if you're on Python, Ruby, Go, PHP, Rust, Elixir, or anything else, you can mint signatures directly.

There are two token kinds:

  • Upload token — signed with your account sk_live_*. Sent in the X-Aura-Signature header on POST /v1/upload.
  • Serve token — signed with the per-project psk_live_*. Sent as ?token= on private-image reads.

Both tokens use the same envelope.

Token envelope

<base64url(JSON(payload))>.<base64url(HMAC-SHA256(<base64url payload>))>

Concretely:

  1. Serialize payload as canonical JSON (any UTF-8 JSON encoder is fine — order doesn't matter).
  2. Base64url-encode the serialized bytes (no padding, +-, /_).
  3. Compute HMAC-SHA256(secret, <base64url payload>) over the encoded payload bytes (UTF-8 of the base64url string).
  4. Base64url-encode the HMAC digest (no padding).
  5. Join with a literal ..

The token is opaque to clients — the server splits on the last ., decodes the payload, recomputes the HMAC, and rejects on mismatch or expiry.

Upload token

Signed with sk_live_* (your account secret).

Payload

{
  projectName: string;            // e.g. "my-app"
  maxSize: number;                // bytes
  allowedTypes: string[];         // e.g. ["image/*"] or ["image/jpeg","image/png"]
  iat: number;                    // issued-at, Unix seconds
  exp: number;                    // expiry, Unix seconds
  visibility?: "public" | "private";  // omit for public
}

Server checks

  • HMAC matches.
  • exp >= now (Unix seconds, server clock).
  • projectName is not in the reserved set (api, admin, cdn, health, registry, static, test, v1).
  • The uploaded file's magic bytes match allowedTypes.
  • The uploaded file size ≤ maxSize.

Example payload

{
  "projectName": "my-app",
  "maxSize": 5242880,
  "allowedTypes": ["image/*"],
  "iat": 1745712000,
  "exp": 1745715600,
  "visibility": "private"
}

Sending it

POST https://cdn.auraimage.ai/v1/upload
X-Aura-Signature: <token>
Content-Type: multipart/form-data

Form fields:

  • file — the image bytes.
  • filename — optional, but recommended for SEO.

Serve token

Signed with psk_live_* (the per-project serve secret). Required only for private-image reads.

Payload

Field names are deliberately short to keep URLs compact.

{
  p: string;     // projectName
  f: string;     // filename
  exp: number;   // Unix seconds
}

Constraints

  • 60 <= (exp - now) <= 604800 (1 minute to 7 days). The TypeScript SDK clamps; non-JS implementations must clamp themselves.
  • p and f must match the URL path components exactly. Renaming or path-substitution is rejected.

Sending it

GET https://cdn.auraimage.ai/<p>/<f>?token=<serve-token>

The server returns 403 { "message": "Invalid or expired signature" } on any mismatch (bad HMAC, expired, wrong path, wrong project).

Reference: Python upload signer

import base64, hashlib, hmac, json, time

def b64url(b: bytes) -> str:
    return base64.urlsafe_b64encode(b).decode("ascii").rstrip("=")

def sign_upload(secret_key: str, project_name: str, *,
                max_size: int = 5_242_880,
                allowed_types = ("image/*",),
                expires_in: int = 3600,
                visibility: str | None = None) -> str:
    now = int(time.time())
    payload = {
        "projectName": project_name,
        "maxSize": max_size,
        "allowedTypes": list(allowed_types),
        "iat": now,
        "exp": now + expires_in,
    }
    if visibility:
        payload["visibility"] = visibility
    encoded = b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
    sig = hmac.new(secret_key.encode("utf-8"), encoded.encode("ascii"), hashlib.sha256).digest()
    return f"{encoded}.{b64url(sig)}"

Reference: Python serve signer

def sign_serve(serve_secret: str, project_name: str, filename: str,
               expires_in: int = 600) -> str:
    expires_in = max(60, min(604800, expires_in))
    payload = {"p": project_name, "f": filename, "exp": int(time.time()) + expires_in}
    encoded = b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
    sig = hmac.new(serve_secret.encode("utf-8"), encoded.encode("ascii"), hashlib.sha256).digest()
    return f"{encoded}.{b64url(sig)}"

Reference: Ruby

require "base64"
require "json"
require "openssl"

def b64url(bytes)
  Base64.urlsafe_encode64(bytes).delete("=")
end

def sign_upload(secret_key, project_name, max_size: 5_242_880,
                allowed_types: ["image/*"], expires_in: 3600, visibility: nil)
  now = Time.now.to_i
  payload = {
    projectName: project_name,
    maxSize: max_size,
    allowedTypes: allowed_types,
    iat: now,
    exp: now + expires_in
  }
  payload[:visibility] = visibility if visibility
  encoded = b64url(JSON.generate(payload))
  sig = OpenSSL::HMAC.digest("SHA256", secret_key, encoded)
  "#{encoded}.#{b64url(sig)}"
end

Verification (server-side reference)

If you're building a fixture or running an offline check, verification is symmetric:

  1. Split on the last . (the payload itself contains no dots; the signature is base64url with no padding).
  2. Recompute HMAC-SHA256(secret, <encoded payload>) and compare with constant-time equality to the decoded signature.
  3. JSON-parse the decoded payload.
  4. Reject if exp < now, or — for serve tokens — if p / f don't match the URL path.

Common bugs

  • Hashing the JSON instead of the encoded form. The HMAC input is the base64url-encoded payload string, not the raw JSON. Sign exactly what you concatenate.
  • Padded base64. Strip =. The reference verifier accepts unpadded base64url only.
  • Milliseconds vs seconds. iat and exp are Unix seconds, not milliseconds. Off-by-1000 expiry is the most common bug.
  • Wrong secret for serve tokens. Upload tokens use sk_live_*; serve tokens use the per-project psk_live_*. Mixing them returns 403 with no helpful message.
  • Re-ordering JSON keys after signing. Sign and send the same encoded payload string. Don't decode-and-re-encode in transit.