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 theX-Aura-Signatureheader onPOST /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:
- Serialize
payloadas canonical JSON (any UTF-8 JSON encoder is fine — order doesn't matter). - Base64url-encode the serialized bytes (no padding,
+→-,/→_). - Compute
HMAC-SHA256(secret, <base64url payload>)over the encoded payload bytes (UTF-8 of the base64url string). - Base64url-encode the HMAC digest (no padding).
- 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).projectNameis 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-dataForm 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.pandfmust 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)}"
endVerification (server-side reference)
If you're building a fixture or running an offline check, verification is symmetric:
- Split on the last
.(the payload itself contains no dots; the signature is base64url with no padding). - Recompute
HMAC-SHA256(secret, <encoded payload>)and compare with constant-time equality to the decoded signature. - JSON-parse the decoded payload.
- Reject if
exp < now, or — for serve tokens — ifp/fdon'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.
iatandexpare 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-projectpsk_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.