> ## Documentation Index
> Fetch the complete documentation index at: https://docs.openinary.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Signed URLs

> Restrict transformation access with HMAC-signed URLs that cannot be forged or tampered with

`/t/*` is public: anyone who knows a URL can request any transformation of it. Signed URLs close that off. Your backend signs the exact URL it wants to allow, and the server refuses anything else.

```
/authenticated/s--{signature}/{transformations}/{file-path}
```

The signature is an HMAC-SHA256 of the path, keyed on `API_SECRET`, truncated to 16 hex characters. On every request the server recomputes it and compares in constant time. A signature that does not verify gets `401`. One that is missing or the wrong length gets `400`, before any comparison happens.

Sign `{transformations}/{file-path}` when there are transformations, `{file-path}` alone when there are none. Change either part of the URL and the signature stops matching, which is what stops a visitor editing `w_200` into `w_5000`.

## Setup

### 1. Set `API_SECRET`

Add `API_SECRET` to your environment. It must be at least 16 characters long.

Scaffolded with the [Openinary CLI](https://docs.openinary.dev/quickstart.md)? A 64-character `API_SECRET` is already in your `.env`, so skip to step 2.

```bash theme={null}
# Generate a strong secret
openssl rand -hex 32
```

Then add it to your environment:

```bash theme={null}
API_SECRET=your-generated-secret-here
```

> [!WARNING]
>
> Keep `API_SECRET` private. Anyone who obtains it can generate valid signatures
> for any file and transformation.

### 2. Generate signatures in your backend

Use `crypto.createHmac` (Node.js) or an equivalent library in your language. Never generate signatures client-side.

> [tab] TypeScript / Node.js
>
> ```ts theme={null}
> import crypto from "node:crypto";
>
> const SIGNATURE_LENGTH = 16;
>
> function generateSignature(
>   transformations: string,
>   filePath: string,
>   secret: string,
> ): string {
>   const stringToSign = transformations
>     ? `${transformations}/${filePath}`
>     : filePath;
>
>   return crypto
>     .createHmac("sha256", secret)
>     .update(stringToSign)
>     .digest("hex")
>     .substring(0, SIGNATURE_LENGTH);
> }
>
> function buildSignedUrl(
>   baseUrl: string,
>   transformations: string,
>   filePath: string,
>   secret: string,
> ): string {
>   const signature = generateSignature(transformations, filePath, secret);
>   const parts = [baseUrl, "authenticated", `s--${signature}`];
>   if (transformations) parts.push(transformations);
>   parts.push(filePath);
>   return parts.join("/");
> }
>
> // Example
> const url = buildSignedUrl(
>   "https://media.example.com",
>   "w_800,h_600,c_fill,f_webp",
>   "uploads/photo.jpg",
>   process.env.API_SECRET!,
> );
>
> console.log(url);
> // → https://media.example.com/authenticated/s--a1b2c3d4e5f6a7b8/w_800,h_600,c_fill,f_webp/uploads/photo.jpg
> ```

> [tab] Python
>
> ```python theme={null}
> import hmac
> import hashlib
>
> SIGNATURE_LENGTH = 16
>
> def generate_signature(transformations: str, file_path: str, secret: str) -> str:
>     string_to_sign = f"{transformations}/{file_path}" if transformations else file_path
>     sig = hmac.new(secret.encode(), string_to_sign.encode(), hashlib.sha256).hexdigest()
>     return sig[:SIGNATURE_LENGTH]
>
> def build_signed_url(base_url: str, transformations: str, file_path: str, secret: str) -> str:
>     signature = generate_signature(transformations, file_path, secret)
>     parts = [base_url.rstrip("/"), "authenticated", f"s--{signature}"]
>     if transformations:
>         parts.append(transformations)
>     parts.append(file_path)
>     return "/".join(parts)
>
> # Example
> url = build_signed_url(
>     "https://media.example.com",
>     "w_800,h_600,c_fill,f_webp",
>     "uploads/photo.jpg",
>     "your-api-secret"
> )
> print(url)
> # → https://media.example.com/authenticated/s--a1b2c3d4e5f6a7b8/w_800,h_600,c_fill,f_webp/uploads/photo.jpg
> ```

> [tab] PHP
>
> ```php theme={null}
> <?php
> const SIGNATURE_LENGTH = 16;
>
> function generateSignature(string $transformations, string $filePath, string $secret): string {
>     $stringToSign = $transformations ? "{$transformations}/{$filePath}" : $filePath;
>     $full = hash_hmac('sha256', $stringToSign, $secret);
>     return substr($full, 0, SIGNATURE_LENGTH);
> }
>
> function buildSignedUrl(string $baseUrl, string $transformations, string $filePath, string $secret): string {
>     $signature = generateSignature($transformations, $filePath, $secret);
>     $parts = [rtrim($baseUrl, '/'), 'authenticated', "s--{$signature}"];
>     if ($transformations) $parts[] = $transformations;
>     $parts[] = $filePath;
>     return implode('/', $parts);
> }
>
> // Example
> $url = buildSignedUrl(
>     'https://media.example.com',
>     'w_800,h_600,c_fill,f_webp',
>     'uploads/photo.jpg',
>     getenv('API_SECRET')
> );
> echo $url;
> // → https://media.example.com/authenticated/s--a1b2c3d4e5f6a7b8/w_800,h_600,c_fill,f_webp/uploads/photo.jpg
> ```

For the endpoint URL format and error responses, see the [Authenticated Transform API reference](https://docs.openinary.dev/api-reference/media/authenticated-transform.md).

## Security considerations

> [accordion] Signatures are tied to exact transformations and paths
>
> Changing any character in the transformation string or file path invalidates the signature. A signature for `w_800,h_600/photo.jpg` cannot be reused for `w_400,h_300/photo.jpg`.

> [accordion] Timing-safe comparison prevents timing attacks
>
> The server uses `crypto.timingSafeEqual` to compare signatures. This prevents
> attackers from deducing valid signatures by measuring response time
> differences.

> [accordion] The signature does not expire
>
> Signed URLs do not have a built-in expiry. If you need time-limited URLs,
> append an expiry timestamp to the file path or transformation string and
> enforce it in a reverse proxy or middleware layer.

> [accordion] Rotate API\_SECRET if compromised
>
> If `API_SECRET` is leaked, immediately replace it with a new value and restart the server. All previously generated signed URLs will become invalid.

## Related

> [card] [Image Transformations](https://docs.openinary.dev/media-transformations/image-transformations.md)
>
> Full reference for transformation parameters you can sign.

> [card] [Server Configuration](https://docs.openinary.dev/configuration/server.md)
>
> How to set `API_SECRET` and other server options.

> [card] [Sign Upload](https://docs.openinary.dev/api-reference/files/upload-sign.md)
>
> A separate use of `API_SECRET`: mint short-lived signatures for direct client-side uploads, rather than signing transformation URLs.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.