# Why Discord CDN URLs Expire and How to Build a Permanent Media Rehosting Pipeline

> An architectural analysis of Discord's CDN attachment expiration policy (HMAC signed query parameters), its impact on bots, websites, and webhooks, and how to build an automated rehosting pipeline with Node.js and Python.

Source: https://x02.me/blog/discord-cdn-expiration-permanent-media · 2026-08-25 · X02 Team

In late 2023, Discord announced a massive architectural shift in how it serves user attachments. Starting in early 2024, all direct media links hosted on `cdn.discordapp.com` and `media.discordapp.com` were upgraded to enforce **ephemeral HMAC signature tokens**.

For thousands of developer projects, Discord bots, community wikis, and forum embeds that used Discord as a makeshift free Content Delivery Network (CDN), this change caused broken images, 403 Forbidden errors, and corrupted user archives worldwide.

In this article, we explain the mechanics behind Discord's signed expiration URLs, why using Discord as a CDN was always flawed, and how to construct an automated media rehosting pipeline using X02's API to ensure your attachments remain permanent forever.

---

## 1. Deconstructing Discord's Ephemeral URL Signature

Historically, Discord attachment URLs followed a static, predictable structure:
`https://cdn.discordapp.com/attachments/123456789/987654321/image.png`

Under the updated security enforcement, all URLs generated within Discord channels now include mandatory authentication query parameters:

```
https://cdn.discordapp.com/attachments/1057573344855207966/1234567890/asset.png?ex=66d1f08a&is=66d09f0a&hmac=a9f8b7c6d5e4...
```

### The Three Query Parameters:
1. **`ex` (Hexadecimal Timestamp)**: The exact Unix expiration timestamp when the link becomes invalid (typically 24 hours from link generation).
2. **`is` (Hexadecimal Timestamp)**: The link issuance timestamp when Discord's edge generated the signed string.
3. **`hmac` (Cryptographic Signature)**: A SHA-256 HMAC hash signed by Discord's private server key across the path and timestamps.

Once `Date.now() > ex`, attempting to fetch or embed that image outside of active Discord clients returns:
```http
HTTP/1.1 403 Forbidden
Content-Type: text/plain

Access Denied: Signature has expired.
```

> [!WARNING]
> While active Discord desktop and mobile apps refresh signatures in real-time when rendering message channels, any link copied outside Discord (such as a database record, blog post, external dashboard, or third-party website) will break permanently after 24 hours.

---

## 2. The Solution: Automated Ingestion & Permanent Rehosting

To ensure that images, bug report attachments, user submissions, and receipts remain accessible indefinitely, your backend or bot must implement an **intercept-and-rehost pipeline**.

![Architecture Diagram](/blog-diagrams/discord-cdn-expiration-permanent-media-1.svg)

---

## 3. Implementation in Node.js (Discord.js v14)

Here is a production-tested snippet using `discord.js` that automatically intercepts incoming user attachments and re-hosts them to X02:

```typescript
import { Client, GatewayIntentBits, Message } from "discord.js";
import FormData from "form-data";
import fetch from "node-fetch";

const client = new Client({
  intents: [
    GatewayIntentBits.Guilds,
    GatewayIntentBits.GuildMessages,
    GatewayIntentBits.MessageContent,
  ],
});

const X02_API_KEY = process.env.X02_API_KEY!;
const X02_UPLOAD_URL = "https://x02.me/api/upload";

/**
 * Downloads ephemeral Discord attachment and re-uploads to permanent X02 storage.
 */
async function rehostToX02(ephemeralUrl: string, originalFilename: string): Promise<string | null> {
  try {
    // 1. Fetch binary stream from Discord
    const discordResponse = await fetch(ephemeralUrl);
    if (!discordResponse.ok) throw new Error(`Discord download failed: ${discordResponse.statusText}`);

    const buffer = await discordResponse.buffer();

    // 2. Build multipart payload
    const form = new FormData();
    form.append("file", buffer, { filename: originalFilename });

    // 3. Dispatch to X02
    const x02Response = await fetch(X02_UPLOAD_URL, {
      method: "POST",
      headers: {
        "x-api-key": X02_API_KEY,
        ...form.getHeaders(),
      },
      body: form,
    });

    const data: any = await x02Response.json();
    if (data.success && data.url) {
      return data.url; // Guaranteed permanent URL
    }
    return null;
  } catch (error) {
    console.error("Rehosting error:", error);
    return null;
  }
}

client.on("messageCreate", async (message: Message) => {
  if (message.author.bot) return;

  // Intercept images / attachments
  if (message.attachments.size > 0) {
    for (const [, attachment] of message.attachments) {
      const permanentUrl = await rehostToX02(attachment.url, attachment.name);
      
      if (permanentUrl) {
        console.log(`Successfully preserved media: ${permanentUrl}`);
        // Store permanentUrl into your PostgreSQL / Prisma database here
      }
    }
  }
});

client.login(process.env.DISCORD_BOT_TOKEN);
```

---

## 4. Implementation in Python (discord.py / aiohttp)

For Python developers, here is the async equivalent using `discord.py` and `aiohttp`:

```python
import os
import aiohttp
import discord
from discord.ext import commands

intents = discord.Intents.default()
intents.message_content = True

bot = commands.Bot(command_prefix="!", intents=intents)
X02_API_KEY = os.getenv("X02_API_KEY")

async def rehost_to_x02(session: aiohttp.ClientSession, attachment_url: str, filename: str) -> str:
    async with session.get(attachment_url) as resp:
        if resp.status != 200:
            return None
        file_bytes = await resp.read()

    data = aiohttp.FormData()
    data.add_field('file', file_bytes, filename=filename)

    headers = {"x-api-key": X02_API_KEY}
    async with session.post("https://x02.me/api/upload", data=data, headers=headers) as resp:
        res = await resp.json()
        if res.get("success"):
            return res.get("url")
    return None

@bot.event
async def on_message(message):
    if message.author.bot:
        return

    if message.attachments:
        async with aiohttp.ClientSession() as session:
            for att in message.attachments:
                permanent_link = await rehost_to_x02(session, att.url, att.filename)
                print(f"Rehosted to X02: {permanent_link}")

    await bot.process_commands(message)

bot.run(os.getenv("DISCORD_TOKEN"))
```

---

## Summary

Discord's shift to ephemeral CDN URLs reinforces a critical lesson in cloud architecture: **never use communication platforms as persistent data stores**.

By integrating an automated rehosting layer into your bot workflows, you ensure data permanence, retain control over your media assets, and protect your user interfaces from decaying 403 errors.
