Discord Attachment URLs Expire — Stop Storing Them in Your Database

·5 min read·Updated
discordbotsattachmentsstoragetroubleshooting

If your bot saves Discord attachment URLs and serves them back later — a starboard, an image gallery, a moderation log, anything that remembers a picture — those links stop working. Discord attachment CDN URLs are signed and time-limited, which you can see in the query string: ex, is, and hm. The images look fine when you test it and break days later, which is why this usually surfaces as "my bot's old entries are all broken" rather than as a bug you can catch. Don't store the URL. Store the message and channel IDs and re-fetch, or store the file itself — and if you're only putting the image in an embed, there's a fix that costs nothing.

What the parameters mean

Straight from Discord's reference:

  • ex — "Hex timestamp indicating when an attachment CDN URL will expire"
  • is — "Hex timestamp indicating when the URL was issued"
  • hm — "Unique signature that remains valid until the URL's expiration"

So the URL isn't an address, it's a signed, expiring grant. Copying it into a database is like storing a session token and expecting it to work next month.

Why it hits bots harder than people

In the Discord client this is invisible, because links get refreshed as you scroll. Your bot doesn't scroll — it wrote the string down once.

The pattern shows up in almost every bot that deals with media:

  • A starboard storing attachment.url alongside the star count.
  • A gallery or "best of" command that replays saved images.
  • A moderation log recording what was posted before it was deleted.
  • A web dashboard displaying images your bot indexed.

All of these test perfectly, because you test them immediately.

Fix 1: re-fetch instead of remembering

The cheapest correct answer. Store the channel ID and message ID, not the URL. When you need the image, fetch the message and use the URL it gives you.

Discord's docs are explicit that this works: "Discord automatically refreshes attachment CDN URLs that appear within the client, so when your app receives a payload with a signed URL (like when you fetch a message), it will be valid."

Two IDs are also smaller than a URL, and they let you recover the whole message later — author, timestamp, content — rather than just one link.

The trade-off: it costs an API call at read time, and if the original message is deleted, the attachment is gone for good. For a starboard that's arguably correct behaviour anyway.

Fix 2: for embeds, strip the parameters

This one is under-known and free. If you're putting the image into an embed rather than storing it, you don't need a valid signature at all.

Per the same reference: "when passing CDN URLs into API fields, like url in an embed image object and avatar_url for webhooks, your app can pass the CDN URL without any parameters as the value and Discord will automatically render and refresh the URL."

So drop everything from ? onwards and hand Discord the bare URL:

bare_url = attachment.url.split("?")[0]

Discord re-signs it on render. No storage, no API call, no expiry handling. If your use case is entirely "show this image in an embed", this is the whole solution.

Fix 3: store the bytes yourself

When you genuinely need the file to outlive the message — an archive, a dataset, anything that must survive deletion — download it and keep it.

That turns a Discord problem into a storage problem, and storage on a container platform has its own rules:

  • On a volume, files persist across deploys, but they count against your disk limit — and exceeding that limit stops every app in your account, not just this one. Media that grows with your bot's popularity is exactly the kind of thing that gets you there.
  • In an external bucket (S3, R2, B2), it's out of your quota entirely and outlives the app. For anything media-heavy this is the better default.

Whichever you pick, cap what you accept. Discord attachments can be large, and "save every image posted in this server" is an unbounded commitment.

Don't proxy the images through your bot

The tempting middle ground — your app fetches from Discord on demand and streams the image to whoever asked — is the worst of the options here.

Every request becomes your bandwidth and your CPU time budget, which on a fractional core is a slow way to serve a file. And you've added a hop that fails whenever Discord is slow. If you're serving images to a web frontend, serve them from a bucket and let the storage provider do the work.

A quick audit

Worth five minutes if you have a bot storing media:

  1. Search your code for attachment.url or .url being written to storage. Every one is a future broken link.
  2. Check whether the stored value has a ?ex= in it. If yes, it's already expiring.
  3. Decide per use case: embed only → strip the parameters. Needs to persist → store IDs and re-fetch, or store the bytes.
  4. Backfill if you can. For rows that still have valid message IDs, re-fetching and re-storing gets you back to a working state.

FAQ

Yes. Attachment CDN URLs are signed with an expiry — the ex parameter is the expiration timestamp and hm is the signature that's valid until then.

Why do my bot's old images stop loading?

The saved URLs expired. Store the channel and message IDs instead and re-fetch, which returns a currently valid URL.

How do I show an attachment in an embed without it breaking?

Pass the CDN URL with the query parameters removed. Discord re-signs and refreshes it when rendering.

Should I download and store attachments?

Only when the file must outlive the message. Then use a bucket rather than a volume for anything that grows, since disk overruns stop every app on the account.

Can I just re-sign the URL myself?

No. The signature is Discord's. Get a fresh URL by fetching the message through the API.

Does this affect avatars and emoji too?

The refresh-on-render behaviour applies to CDN URLs passed into API fields like avatar_url. The same rule holds: don't archive signed URLs.

Wrapping up

This is a nice example of a bug that hosting can't cause and can't fix — the URLs would expire identically on your laptop. What hosting decides is what your options cost: re-fetching is free, embeds are free once you know about stripping the query string, and storing files yourself lands you in disk quotas and bandwidth. Pick per use case, and treat any signed URL in a database as a broken link with a delay on it.