# Signature Spec (https://auraimage.ai/docs/signature-spec)



`@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` — see the [upload flow](/docs/upload) for what the edge validates on arrival.
* **Serve token** — signed with the per-project `psk_live_*`. Sent as `?token=` on [private-image reads](/docs/private-images).

Both tokens use the same envelope.

## Token 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 [#upload-token]

Signed with `sk_live_*` (your account secret).

### Payload [#payload]

```ts
{
  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 [#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 [#example-payload]

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

### Sending it [#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 [#serve-token]

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

### Payload [#payload-1]

Field names are deliberately short to keep URLs compact.

```ts
{
  p: string;     // projectName
  f: string;     // image name (extension-less)
  exp: number;   // Unix seconds
}
```

### Constraints [#constraints]

* `60 <= (exp - now) <= 604800` (1 minute to 7 days). The TypeScript SDK clamps; non-JS implementations must clamp themselves.
* `p` must equal the project and `f` must equal the image's extension-less **name**. The transform segment and trailing format extension are **not** bound — one token authorizes every variant and format of the image (and its blurhash fetch).

### Sending it [#sending-it-1]

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

The same token covers any transform segment and extension on that name — `…/<p>/w=800/<name>.avif?token=…` verifies identically. The server returns `403 { "message": "Invalid or expired signature" }` on any mismatch (bad HMAC, expired, wrong name, wrong project).

## Reference: Python upload signer [#reference-python-upload-signer]

```python
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 [#reference-python-serve-signer]

```python
def sign_serve(serve_secret: str, project_name: str, name: str,
               expires_in: int = 600) -> str:
    expires_in = max(60, min(604800, expires_in))
    payload = {"p": project_name, "f": name, "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 [#reference-ruby]

```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) [#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` doesn't match the project or `f` doesn't match the image name.

## Common bugs [#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.


## Related

- [Private Images](https://auraimage.ai/docs/private-images)
- [URL API](https://auraimage.ai/docs/url-api)
- [How AuraImage's Edge CDN Works Under the Hood](https://auraimage.ai/blog/how-auraimage-edge-cdn-works)