Skip to content

Upload Flow

Edge Proxy Pattern — your Secret Key never leaves your server.

AuraImage uses the Edge Proxy Pattern. Your Secret Key never leaves your server; all upload requests are validated at the edge before a single byte reaches storage.

Architecture overview

[Your Backend]  →  Generate HMAC signature (signUpload)

[Your Frontend]  →  POST file + signature  →  [AuraImage Edge]

                                             Pre-flight validation
                                             (HMAC, expiry, magic bytes,
                                              size, origin, rate limit, quota)

                                             [Object Storage]  ←  stored

                                             Returns final URL  →  Your app

Step 1 — Generate a signature (server-side)

Your backend calls signUpload() using your Secret Key. This produces a short-lived HMAC token.

app/api/aura/sign/route.ts
import { AuraImage } from '@auraimage/sdk';

const aura = new AuraImage({
  secretKey: process.env.AURA_SECRET_KEY!,
  projectName: process.env.NEXT_PUBLIC_AURA_PROJECT_NAME!
});

export async function POST() {
  const signature = await aura.signUpload({
    maxSize: '30mb',                // enforced at the edge
    allowedTypes: ['image/*'],      // magic-byte validated
    expiresIn: 3600                 // seconds — token lifetime
  });

  return Response.json({ signature });
}
import { Hono } from 'hono';
import { AuraImage } from '@auraimage/sdk';

const aura = new AuraImage({
  secretKey: process.env.AURA_SECRET_KEY!,
  projectName: process.env.NEXT_PUBLIC_AURA_PROJECT_NAME!
});

const app = new Hono();

app.post('/api/aura/sign', async (c) => {
  const signature = await aura.signUpload({
    maxSize: '30mb',
    allowedTypes: ['image/*'],
    expiresIn: 3600
  });
  return c.json({ signature });
});

The signature encodes your constraints. An attacker who intercepts it cannot upload a larger file or a non-image format — the edge enforces these server-side.

For non-JS backends (Python, Ruby, Go, PHP, …), implement the HMAC scheme directly. See Signature Spec.

signUpload options

OptionTypeDefaultDescription
maxSizestring | number"30mb"Max file size. Bytes (number) or human form ("500kb", "30mb", "2gb").
allowedTypesstring[]["image/*"]MIME patterns enforced via magic-byte detection.
expiresInnumber3600Token lifetime in seconds.
projectNamestringconstructor valueOverride the project for this token.
visibility"public" | "private""public"Initial visibility — see Private Images.
namestringrandomCustom image name/path (e.g. "blog/hero"). Slashes create path segments.
overwritebooleanfalseAllow overwriting an existing image with the same name. Serve URLs are cache-immutable: already-cached variants may keep serving for up to 30 days — bump a ?v= query param on the serve URL (or use a new name) for instant freshness.

signUpload returns Promise<string>. Always await the call.

Server-side upload (from URL)

Use uploadFromUrl() when you already have the image at a public URL — no browser or FormData needed. The SDK fetches the bytes, validates the response, and uploads directly to Auraimage in one call.

Requires cdnUrl set on the AuraImage constructor.

import { AuraImage } from '@auraimage/sdk';

const aura = new AuraImage({
  secretKey: process.env.AURA_SECRET_KEY!,
  projectName: process.env.AURA_PROJECT!,
  cdnUrl: 'https://cdn.auraimage.ai'
});

const result = await aura.uploadFromUrl('https://example.com/photo.jpg');
// { url, name, blurhash, width, height, format, masterFormat, size, visibility }

uploadFromUrl options

OptionTypeDefaultDescription
namestringderived from URLCustom image name/path (extension-less). If omitted, taken from the URL's last path segment with the extension stripped (e.g. .../cat.jpg"cat").
maxSizestring | number"30mb"Max file size. Bytes or human form ("50mb"). Checked against Content-Length header first, then actual downloaded bytes.
visibility"public" | "private""public"Initial visibility — see Private Images.
overwritebooleanfalseAllow overwriting an existing image with the same name. Serve URLs are cache-immutable: already-cached variants may keep serving for up to 30 days — bump a ?v= query param on the serve URL (or use a new name) for instant freshness.
timeoutnumber30000Fetch timeout in milliseconds.

Error handling

uploadFromUrl throws UploadFromUrlError with a kind discriminant:

KindWhen
invalid-nameNo name was provided and none could be derived from the URL.
fetchThe remote URL is unreachable, returns a non-2xx status, times out, or the response body can't be read.
content-typeThe remote server returned a non-image Content-Type.
sizeThe downloaded file exceeds maxSize.
uploadThe CDN rejected the upload (409 conflict, 413 too large, 415 unsupported format, etc.).

The url property on the error identifies the source URL that failed, and status carries the HTTP status code when available.

import { AuraImage, UploadFromUrlError } from '@auraimage/sdk';

try {
  const result = await aura.uploadFromUrl('https://example.com/photo.jpg');
} catch (err) {
  if (err instanceof UploadFromUrlError) {
    console.error(`Failed (${err.kind}) for ${err.url}: ${err.message}`);
  }
}

Step 2 — Upload from the client

// 1. Fetch a signature from your own backend
const { signature } = await fetch('/api/aura/sign', { method: 'POST' }).then((r) => r.json());

// 2. Build the form data
const formData = new FormData();
formData.append('file', file);                  // the image File object
formData.append('filename', 'hero-photo.jpg');  // optional; improves SEO

// 3. Upload to the AuraImage edge
const response = await fetch('https://cdn.auraimage.ai/v1/upload', {
  method: 'POST',
  headers: { 'X-Aura-Signature': signature },
  body: formData
});

const { url, name, blurhash, width, height } = await response.json();

Upload response

{
  "url": "https://cdn.auraimage.ai/my-app/abc123xyz0-hero-photo",
  "name": "abc123xyz0-hero-photo",
  "blurhash": "LHF$Vb00~q~q9aM{RjxuIURjWBof",
  "width": 2400,
  "height": 1600,
  "format": "jpeg",
  "masterFormat": "jpeg",
  "size": 184320,
  "visibility": "public"
}
FieldDescription
urlCanonical, extension-less CDN URL for the uploaded image.
nameStored image name (extension-less) — pass this to getSignedUrl() / setVisibility() (see Private Images).
blurhash~30-char BlurHash string. Persist alongside the URL for instant placeholder rendering.
width / heightPixel dimensions of the original image.
formatThe format the user uploaded ("jpeg", "png", "heic", …).
masterFormatThe format actually stored on the CDN. Same as format for browser-supported types; transcoded for HEIC / TIFF / BMP.
sizeOriginal byte size.
visibility"public" or "private" based on the signature's visibility option.

url is final — store it. Auto-generated names carry a randomized 10-char prefix, so uploads without an explicit name claim don't collide.

Edge validation

The AuraImage edge runs these checks before the upload reaches storage:

CheckBehavior on failure
HMAC signature401 — Verifies the request was authorized by your Secret Key.
Token expiry401 — Rejects tokens past their expiresIn window.
Reserved project names400api, admin, cdn, health, registry, static, test, v1 are reserved.
Allowed origins403 — Blocks uploads from origins not in your dashboard's allow-list (when configured).
Rate limiting429 — Per-project request throttling.
Quota402 — Plan storage / wallet-guard exceeded.
Content-Type400 — Must be multipart/form-data.
maxSize413 — Enforces the size constraint from the signature.
Magic bytes415 — Reads the first 12 bytes to confirm it's actually an image.
allowedTypes415 — Detected format must match the signature's allow-list.
Tier-gated formats403 — HEIC / HEIF require Pro; TIFF / BMP require Startup.

Retries & backpressure

Under heavy load the edge can reject an upload with a transient status. Every transient rejection carries a Retry-After header (in seconds) — that is the contract to retry against:

StatusMeaningWhat to do
429Per-project rate limit (1,200 requests/min).Wait Retry-After, then retry.
503 + Retry-AfterOrigin at capacity (queue full).Wait Retry-After, then retry.
503 + X-Aura-Origin-State: drainingThe origin task is shutting down.Retry immediately — a fresh request lands on a healthy task.

A compliant client:

  1. Retries 429, 503, and network failures up to 4 attempts total.
  2. Waits Retry-After seconds (falling back to 500 ms when absent) plus random jitter between attempts, so a batch doesn't retry in lockstep.
  3. Keeps batch concurrency at ≤ 8 simultaneous uploads.
  4. For large batches, collects files that exhausted their attempts and re-tries them once more at low concurrency after the batch drains — by then the contention that failed them has usually passed.

uploadMany in @auraimage/sdk/client (and the <AuraUploader /> component) implements all of this out of the box — a 1,000-file batch completes without any retry code on your side. If you upload via raw fetch, implement the steps above; a client that ignores Retry-After will lose files under load and that's expected behavior, not a platform fault.

Input format handling

AuraImage accepts all common input formats:

  • JPEG, PNG, WebP, AVIF, GIF — stored as-is.
  • HEIC / HEIF (iPhone default) — Pro tier and above. Transcoded to a CDN-compatible master on ingest.
  • TIFF / BMP — Startup tier. Transcoded to PNG or JPEG on ingest.
  • JXL — not supported; returns 415.

The format field in the upload response reflects what you uploaded; masterFormat reflects what's stored. On delivery, the master is transcoded to AVIF, WebP, or JPEG via Accept-header negotiation. See URL API for content negotiation details.

BlurHash generation

AuraImage automatically computes a BlurHash for every uploaded image and returns it in the upload response. The <AuraImage /> component uses it for instant placeholder display before the full image arrives.

You can also fetch it later:

GET https://cdn.auraimage.ai/v1/blurhash/<projectName>/<name>
→ { "blurhash": "LHF$Vb00~q~q9aM{RjxuIURjWBof" }

This endpoint is cached like image serving — shared cache, 30-day TTL, ?v honored as a cache-buster, and 404s negatively cached for 60s. For a private image it requires the image's serve token (see Private Images).

Programmatic upload (CLI / MCP)

The aura CLI uploads entire directories:

# Upload all images in /public/assets and print the new URLs
aura upload ./public/assets --project-name my-app

# Emit newline-delimited JSON (filename, url, name, blurhash, dimensions, …) for scripting
aura upload ./public/assets --project-name my-app --json | jq '.url'

The MCP migrate_assets tool does this automatically and rewrites your JSX in the same step. See AI Integration.