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

# Signed URLs

Mint short-lived, cross-origin proxy URLs server-side using HMAC-SHA256.

# Signed URLs

Signed URLs let you authorize proxy traffic without exposing source origins to an allowlist. The signing secret stays on your backend; the URL carries an HMAC + expiry.

## When to use signed mode

- You sign URLs server-side and embed them per-request (most apps).
- Short-lived URLs (e.g. paywalled content, A/B variants).
- You want to proxy sources from origins you don't fully control.

If your sources live on a small set of fixed domains, [allowlist mode](/concepts/allowlist-vs-signed) is simpler — skip this guide.

## Enable signing

```bash
curl -X PUT https://motioness.com/api/projects/$PROJECT_ID/proxy-config \
  -H 'Content-Type: application/json' -b cookie.txt \
  -d '{ "enable_signing": true }'
```

This mints a signing secret on first save. Read it from Project → Settings → Proxy → "Signing secret" — it's shown once. Store it as `MOTIONESS_SIGNING_SECRET` in your backend env.

To rotate it later:

```bash
curl -X POST "https://motioness.com/api/projects/$PROJECT_ID/keys/rotate?which=signing" \
  -b cookie.txt
```

## URL shape

```text
https://motioness.com/v1/{projectKey}/{base64url(srcUrl)}.{format}
  ?sig={hmacSha256Hex}&exp={unixSeconds}
```

| Component | Notes |
|---|---|
| `base64url(srcUrl)` | URL-safe base64 of the **full** source URL with scheme. Strip trailing `=` padding. |
| `sig` | Hex-encoded HMAC-SHA256 of `${projectKey}:${srcUrl}:${exp}` keyed with the signing secret. |
| `exp` | Unix seconds at which the URL expires. Server rejects with 403 once the clock passes this. |

## Recipes

**Node 18+**

```ts
    import crypto from 'node:crypto';
    import { buildProxyUrl } from '@motioness/proxy-client';

    function base64UrlEncode(s: string) {
      return Buffer.from(s).toString('base64url');
    }

    function sign(secret: string, data: string) {
      return crypto.createHmac('sha256', secret).update(data).digest('hex');
    }

    export function motionessSignedUrl(srcUrl: string, ttlSeconds = 3600) {
      const projectKey = process.env.MOTIONESS_PROJECT_KEY!;
      const secret = process.env.MOTIONESS_SIGNING_SECRET!;
      const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
      const sig = sign(secret, `${projectKey}:${srcUrl}:${exp}`);
      return buildProxyUrl({
        origin: 'https://motioness.com',
        projectKey,
        src: srcUrl,
        signature: sig,
        exp,
      });
    }
    ```

  **Cloudflare Workers**

```ts
    async function hmacSha256Hex(secret: string, data: string) {
      const key = await crypto.subtle.importKey(
        'raw',
        new TextEncoder().encode(secret),
        { name: 'HMAC', hash: 'SHA-256' },
        false,
        ['sign'],
      );
      const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data));
      return [...new Uint8Array(sig)]
        .map((b) => b.toString(16).padStart(2, '0'))
        .join('');
    }

    export async function motionessSignedUrl(env: Env, srcUrl: string) {
      const exp = Math.floor(Date.now() / 1000) + 3600;
      const sig = await hmacSha256Hex(env.MOTIONESS_SIGNING_SECRET, `${env.MOTIONESS_PROJECT_KEY}:${srcUrl}:${exp}`);
      const b64 = btoa(srcUrl).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
      return `https://motioness.com/v1/${env.MOTIONESS_PROJECT_KEY}/${b64}.mp4?sig=${sig}&exp=${exp}`;
    }
    ```

  **Python**

```python
    import base64
    import hashlib
    import hmac
    import os
    import time
    from urllib.parse import urlencode

    def motioness_signed_url(src_url: str, ttl_seconds: int = 3600) -> str:
        project_key = os.environ['MOTIONESS_PROJECT_KEY']
        secret = os.environ['MOTIONESS_SIGNING_SECRET'].encode()
        exp = int(time.time()) + ttl_seconds
        msg = f'{project_key}:{src_url}:{exp}'.encode()
        sig = hmac.new(secret, msg, hashlib.sha256).hexdigest()
        body = base64.urlsafe_b64encode(src_url.encode()).rstrip(b'=').decode()
        params = urlencode({'sig': sig, 'exp': exp})
        return f'https://motioness.com/v1/{project_key}/{body}.mp4?{params}'
    ```

  **Go**

```go
    package motioness

    import (
        "crypto/hmac"
        "crypto/sha256"
        "encoding/base64"
        "encoding/hex"
        "fmt"
        "os"
        "time"
    )

    func SignedURL(srcURL string, ttl time.Duration) string {
        key := os.Getenv("MOTIONESS_PROJECT_KEY")
        secret := []byte(os.Getenv("MOTIONESS_SIGNING_SECRET"))
        exp := time.Now().Add(ttl).Unix()
        h := hmac.New(sha256.New, secret)
        h.Write([]byte(fmt.Sprintf("%s:%s:%d", key, srcURL, exp)))
        sig := hex.EncodeToString(h.Sum(nil))
        body := base64.RawURLEncoding.EncodeToString([]byte(srcURL))
        return fmt.Sprintf(
            "https://motioness.com/v1/%s/%s.mp4?sig=%s&exp=%d",
            key, body, sig, exp,
        )
    }
    ```

## Verifying client-side

The browser doesn't verify anything — it just hits the URL. The server verifies. Don't put the signing secret in client-side code.

If you embed signed URLs in HTML returned from your CDN, set a short `exp` and let the cache expire naturally. For pages that update frequently, sign at request time in your edge function or backend.

## Common mistakes

**Forgetting to base64-encode the source**

Signed mode requires the src URL to be encoded. Allowlist mode uses plain text. Mixing them returns 403.

  **Signing the wrong tuple**

The signature MUST be over `${projectKey}:${srcUrl}:${exp}` exactly. Different separator? Forgot to include `exp`? URL-encoded `srcUrl`? Server returns 403 with no detail (security).

    To debug, log the exact string you sign and the exact exp; compare against what the worker reconstructs.

  **Clock skew**

The server rejects when `now > exp`. If your backend's clock is ahead of the worker's, you might over-shorten the TTL. Stick to >= 60s.

  **Using base64 instead of base64url**

`+` and `/` characters break URL parsing. Use base64url (Buffer.toString('base64url') in Node, `RawURLEncoding` in Go, etc.) and strip `=` padding.