# Private Images (https://auraimage.ai/docs/private-images)



Private images are stored unchanged on the CDN, but every read requires a short-lived **serve token** signed with your project's serve secret. Use them for paid content, internal dashboards, per-user uploads, or anything that shouldn't be world-readable.

Public images are the default — pass `visibility: 'private'` to `signUpload()` to opt in.

## Setup [#setup]

Each project has its own **serve secret** (`psk_live_*`), separate from your account-wide signing key (`sk_live_*`). Find it in the [dashboard](https://app.auraimage.ai) → your project → **Settings → Serve Secret**.

Add it to your environment alongside `AURA_SECRET_KEY`:

```bash title=".env.local"
AURA_SECRET_KEY=sk_live_...
AURA_SERVE_SECRET=psk_live_...
NEXT_PUBLIC_AURA_PROJECT_NAME=my-app
NEXT_PUBLIC_AURA_CDN_URL=https://cdn.auraimage.ai
```

Then construct the SDK with both secrets:

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

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

`serveSecret` and `cdnUrl` are only required when you call `getSignedUrl()` or `setVisibility()`. Pure upload flows can omit them.

## Upload as private [#upload-as-private]

Pass `visibility: 'private'` on the upload signature:

```ts
const signature = await aura.signUpload({
  maxSize: '30mb',
  allowedTypes: ['image/*'],
  expiresIn: 3600,
  visibility: 'private'
});
```

The visibility is encoded into the upload token; the edge stores the image as private and returns `visibility: "private"` in the upload response. Public reads of that image return `403`.

## Generate a signed read URL [#generate-a-signed-read-url]

Mint a per-request URL with a TTL on the server:

```ts
const url = await aura.getSignedUrl(uploadResponse.name, {
  expiresIn: 600 // 10 minutes
});
// → https://cdn.auraimage.ai/my-app/abc123xyz0-photo?token=...
```

Pass `url` straight into an `<img>`, `<video poster>`, `<AuraImage>` `src`, or `<source srcset>`. Once the token expires the URL stops resolving.

| Option                | Default           | Min  | Max               |
| --------------------- | ----------------- | ---- | ----------------- |
| `expiresIn` (seconds) | `3600`            | `60` | `604800` (7 days) |
| `cdnUrl`              | constructor value | —    | —                 |

`getSignedUrl()` is server-only (it reads `serveSecret`). Never call it from browser code; render signed URLs in your server component / route handler / RSC.

## Flip an existing image [#flip-an-existing-image]

`setVisibility(name, visibility)` is idempotent and works without re-uploading:

```ts
await aura.setVisibility(uploadResponse.name, 'private');
// later:
await aura.setVisibility(uploadResponse.name, 'public');
```

Returns `{ visibility: 'public' | 'private' }`.

Flipping to private stops new caching immediately, but variants cached while the image was public may continue to be served for up to **30 days** (the platform cache TTL). A visibility flip is not a cache purge — if you need immediate global withdrawal, contact support. Deleting an image follows the same contract. For content that may need to disappear on a deadline, upload it as private from the start.

## CDN behavior [#cdn-behavior]

| Scenario                                   | Status | Body                                                       |
| ------------------------------------------ | ------ | ---------------------------------------------------------- |
| Public image, any URL                      | `200`  | Image                                                      |
| Private image, no `?token=`                | `403`  | `{ "message": "Signed token required for private image" }` |
| Private image, invalid / expired token     | `403`  | `{ "message": "Invalid or expired signature" }`            |
| Private image, token for a different image | `403`  | `{ "message": "Invalid or expired signature" }`            |

The blurhash endpoint enforces the same gate: `GET /v1/blurhash/<projectName>/<name>` for a private image returns `403` without a valid `?token=`. The image's serve token authorizes its blurhash too — there's no separate token to mint.

### Caching [#caching]

Public images: `Cache-Control: public, max-age=31536000, s-maxage=2592000, immutable`. Cached in the CDN's shared cache (30-day TTL) for instant repeat reads worldwide; browsers keep the immutable year.

Private images: `Cache-Control: private, max-age=300`. **Bypass the shared edge cache entirely** — every request hits R2. This means:

* A signed URL is safe to share within its TTL — no other user will see a poisoned cache hit.
* High-volume private serving costs more than public (no shared cache amortization). Prefer public images when access doesn't actually need to be gated.
* Server-Timing on private responses always reflects an origin fetch.

## Common pitfalls [#common-pitfalls]

* **Don't sign in the browser.** `serveSecret` must stay on the server. Mint URLs in a route handler / server action / RSC and render the result.
* **Don't reuse signed URLs across users.** Two users with two TTLs is two `getSignedUrl()` calls. The cost is negligible — it's a CPU operation, no network.
* **One token covers every variant.** The token binds the image `name` — `(project, name, exp)` — so a single token authorizes every transform segment, every format extension, and the blurhash fetch. Put transforms in the path as usual: `/{project}/w=400/{name}?token=...`. A transform in the query string is rejected with `400`, token or not.
* **Picking a TTL.** Short TTLs (60–600s) for content that scrolls past quickly; longer (hours) for pages that linger. Max is 7 days.


## Related

- [Signature Spec](https://auraimage.ai/docs/signature-spec)
- [Upload Flow](https://auraimage.ai/docs/upload)
- [URL API](https://auraimage.ai/docs/url-api)