# Instagram images return 403 "URL signature expired": cause and fixes

**Status, checked 2026-09-18:** the lifetime of a `media_url` link is not published by Meta; each link's `oe` parameter shows its own expiry. Whether ElectricBlaze stores copies of images for connected accounts is not established yet.

> Why `media_url` links from the Instagram API stop loading on static and cached sites, how to read the exact expiry from a link, and three ways to keep images on the page.

Canonical: https://electricblaze.com/developer/instagram-feed/media-url-expired.html

## At a glance

- **Use it when:** Instagram images on your site worked at deploy time and now fail with `403`, or opening a `media_url` directly shows `URL signature expired`.
- **Do not use it when:** the API call itself fails or returns no posts. That is a token or permission problem; see [feed not working](https://electricblaze.com/developer/instagram-feed/feed-not-working.html).
- **What the human does:** nothing for the first fix. The copy-on-a-schedule fix needs a repository or bucket the site is served from, and the Instagram token saved as a CI secret.
- **Next step:** decode one broken image URL with the script below. If `expiresAt` is in the past, pick a fix by where your HTML is rendered.

## Why the images break

The API does not hand out permanent image addresses. Each `media_url` is a signed link to Instagram's CDN, and the signature has an end date.

A developer report describes the mechanism: the `oe` query parameter holds the expiry as a hexadecimal Unix timestamp, and after that moment the CDN answers `403` with the body `URL signature expired` ([verbb/social-feeds issue 21 (2026-01)](https://github.com/verbb/social-feeds/issues/21), checked 2026-09-18). That source is a public issue tracker, not Meta, so treat it as secondary.

How long a link lives is not documented. A question about it on the Meta developer forum has no official answer ([Meta developer community thread](https://developers.facebook.com/community/threads/889163498159993/), checked 2026-09-18), so a number of hours quoted in a blog post is someone's observation, not a contract.

The code that fetched the posts is rarely at fault. What breaks is the place where the links were stored:

- **Static export or SSG.** The build writes the links of that moment into HTML that is then served for weeks.
- **ISR or a CDN cache.** A `revalidate` window or `s-maxage` longer than the remaining life of the links keeps serving dead URLs until the next regeneration.
- **A JSON cache.** Posts saved to KV, Redis or a file at fetch time go stale the same way.
- **An image optimizer.** `next/image` and similar proxies fetch the original again on a cache miss and receive the same `403`.

A dev server usually hides the problem because it calls the API on each request, so every link it renders is fresh. Handle `thumbnail_url` and the URLs inside `children` the same way as `media_url` unless you have checked that they carry no `oe`.

## Read the expiry from oe

The `oe` value is seconds since 1970, written in hexadecimal. `parseInt(oe, 16)` gives Unix seconds; multiply by 1000 for a JavaScript `Date`. In the browser console the same check on a broken image is one line: `new Date(parseInt(new URL(img.src).searchParams.get("oe"), 16) * 1000)`.

decode-oe.mjs:

```js
// Prints when an Instagram CDN link stops working.
// Usage: node decode-oe.mjs "<media_url or thumbnail_url>"
const link = new URL(process.argv[2]);
const oe = link.searchParams.get("oe");

if (!oe) {
  console.log(JSON.stringify({ oe: null, note: "no oe parameter: expiry unknown" }));
} else {
  const unixSeconds = parseInt(oe, 16);
  const expiresAt = new Date(unixSeconds * 1000);
  console.log(JSON.stringify({
    oe,
    unixSeconds,
    expiresAt: expiresAt.toISOString(),
    expired: expiresAt.getTime() <= Date.now(),
  }, null, 2));
}
```

Output for an example link with `oe=6AB0F580`, run on 2026-09-18 (illustrative value, not a measured lifetime):

```json
{
  "oe": "6AB0F580",
  "unixSeconds": 1789982080,
  "expiresAt": "2026-09-21T09:14:40.000Z",
  "expired": false
}
```

Run it on a link fresh from the API and on a broken one. The fresh link shows how much time a new response gives you. The broken one confirms that expiry, and not a token problem, emptied the page.

Do not hardcode what you observe. The lifetime is Meta's to change, and nothing guarantees that every link in one response expires at the same moment.

## Fix 1: fetch again before the links expire

When a server renders the page, cache the posts for less time than their links live. Instead of guessing a number, derive it from the response: take the earliest `oe` and subtract a safety margin.

lib/instagram-cache.ts:

```ts
// Seconds until the first Instagram CDN link in a /me/media response expires, minus a margin.
type Item = { media_url?: string; thumbnail_url?: string; children?: { data: Item[] } };

function links(items: Item[]): string[] {
  return items
    .flatMap((m) => [m.media_url, m.thumbnail_url, ...links(m.children?.data ?? [])])
    .filter((u): u is string => Boolean(u));
}

export function secondsUntilFirstExpiry(items: Item[], marginSec = 600): number | null {
  const expiries = links(items)
    .map((u) => new URL(u).searchParams.get("oe"))
    .filter((oe): oe is string => oe !== null)
    .map((oe) => parseInt(oe, 16));
  if (expiries.length === 0) return null; // no readable expiry: choose a short TTL yourself
  const now = Math.floor(Date.now() / 1000);
  return Math.max(0, Math.min(...expiries) - now - marginSec);
}
```

Use the result wherever the cache lifetime can be set per response: `Cache-Control: s-maxage=<seconds>` from a route handler or serverless function, or the TTL of a KV entry. Each regeneration calls the API, receives new signatures and restarts the clock.

Where the window must be fixed in advance, as with a static `revalidate` value in Next.js, log the computed number for a few days and set the window well below the smallest value you saw.

This fix needs a server at request time. GitHub Pages and other static hosts have none, and calling `graph.instagram.com` from the browser would expose the token. On those hosts use fix 2 or 3.

## Fix 2: copy the images on a schedule

Download each image once, while its link is still valid, and serve that copy. A copy has no signature, so it keeps loading until you delete it. A scheduled CI job is enough for most sites:

scripts/sync-instagram.mjs:

```js
// Downloads the latest posts and one cover image per post into public/instagram/.
// Runs in CI. IG_ACCESS_TOKEN comes from repository secrets and never reaches the browser.
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";

const OUT = "public/instagram";
const token = process.env.IG_ACCESS_TOKEN;
if (!token) throw new Error("IG_ACCESS_TOKEN is not set");

const api = new URL("https://graph.instagram.com/v25.0/me/media");
api.searchParams.set("fields", "id,media_type,media_url,thumbnail_url,permalink,timestamp,children{media_type,media_url,thumbnail_url}");
api.searchParams.set("limit", "12");
api.searchParams.set("access_token", token);

const res = await fetch(api);
if (!res.ok) throw new Error(`Instagram API ${res.status}: ${await res.text()}`);
const { data } = await res.json();

// Photo: media_url. Video: thumbnail_url. Carousel: the same rule on the first slide.
function coverOf(m) {
  const item = m.media_type === "CAROUSEL_ALBUM" ? m.children?.data?.[0] : m;
  return item?.media_type === "VIDEO" ? item.thumbnail_url : item?.media_url;
}

await mkdir(OUT, { recursive: true });
const posts = [];
for (const m of data) {
  const src = coverOf(m);
  if (!src) continue; // media_url is omitted for copyright-flagged media
  const img = await fetch(src);
  if (!img.ok) continue; // a fresh link should load; if not, the post waits for the next run
  const ext = img.headers.get("content-type")?.includes("webp") ? "webp" : "jpg";
  const file = `${m.id}.${ext}`;
  await writeFile(`${OUT}/${file}`, Buffer.from(await img.arrayBuffer()));
  posts.push({ id: m.id, mediaType: m.media_type, url: m.permalink, image: `/instagram/${file}`, publishedAt: m.timestamp });
}

// Delete copies of posts that are no longer in the response.
const keep = new Set(posts.map((p) => p.image.split("/").pop()));
for (const f of await readdir(OUT)) {
  if (f !== "feed.json" && !keep.has(f)) await rm(`${OUT}/${f}`);
}
await writeFile(`${OUT}/feed.json`, JSON.stringify({ fetchedAt: new Date().toISOString(), posts }, null, 2) + "\n");
```

.github/workflows/instagram-sync.yml:

```yaml
name: Sync Instagram
on:
  schedule:
    - cron: "23 5 * * *" # sets how soon new posts appear; copies stay valid between runs
  workflow_dispatch:
permissions:
  contents: write
jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 24
      - run: node scripts/sync-instagram.mjs
        env:
          IG_ACCESS_TOKEN: ${{ secrets.IG_ACCESS_TOKEN }}
      - name: Commit changed files
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add public/instagram
          git diff --cached --quiet || { git commit -m "Sync Instagram posts"; git push; }
```

`public/instagram/feed.json` written by the script (example values):

```json
{
  "fetchedAt": "2026-09-18T05:23:41.512Z",
  "posts": [
    {
      "id": "17900000000000001",
      "mediaType": "IMAGE",
      "url": "https://www.instagram.com/p/EXAMPLE1/",
      "image": "/instagram/17900000000000001.jpg",
      "publishedAt": "2026-09-12T07:41:00+0000"
    },
    {
      "id": "17900000000000002",
      "mediaType": "VIDEO",
      "url": "https://www.instagram.com/reel/EXAMPLE2/",
      "image": "/instagram/17900000000000002.jpg",
      "publishedAt": "2026-09-10T16:05:00+0000"
    }
  ]
}
```

Three details decide whether this holds up in production:

- **The token still expires.** A long-lived token is valid for 60 days ([Meta: access_token reference](https://developers.facebook.com/docs/instagram-platform/reference/access_token), checked 2026-09-18), so the job fails after that unless someone refreshes it. See [access tokens](https://electricblaze.com/developer/instagram-feed/access-token.html).
- **Removed posts leave the site.** The script deletes files that are no longer in the latest response, so a post the owner deletes on Instagram disappears here on the next run.
- **Videos are not copied.** The script keeps the cover frame and links video posts to `permalink`. For playback on the page, upload the video files to object storage, not to the repository.

Committing images works for a small feed on GitHub Pages or Netlify. For more posts, upload to S3, Cloudflare R2 or Supabase Storage in the same job and write the public object URLs into `feed.json` instead of local paths.

## Fix 3: a hosted feed that serves copies

Some feed services download media and serve it from their own servers; others pass Instagram's signed links through. That can change over time, so check it before you rely on a vendor for a static site. The images column of the [feed API comparison](https://electricblaze.com/developer/instagram-feed/comparison.html#data) records, with a check date, which services keep copies.

A quick test works for any service: fetch its feed, pick an image URL, and look for an `oe=` parameter or an Instagram CDN host such as `cdninstagram.com` or `fbcdn.net`. If the link carries `oe`, decode it with the script above.

## What ElectricBlaze can promise today

What we observed, kept apart from what we do not know:

- **The widget asks for its data on every page load.** Each call to the package endpoint returns a newly signed data address (observed 2026-09-17), so the post list is never baked into your HTML and a static site does not freeze the feed at build time.
- **Where the images of a connected account are served from is not established yet.** They may be Instagram CDN links or copies; we have not verified either and do not claim one. The demo feed's images sit on `s.electricblaze.com`, which says nothing about real accounts.
- **The CLI 0.1.2 ships demo data only.** How the 0.2 JSON API will serve images of real posts is not established yet.

If image longevity on a static site is a hard requirement today, fix 2 is the option you control end to end.

## FAQ

### How long does an Instagram media_url stay valid?

Meta does not say; a forum question about it has no official answer (checked 2026-09-18). Decode the `oe` parameter of your own links and cache for less than the time it shows.

### Can I renew an expired media_url?

Not by editing the link. Request the post again, for example `GET /v25.0/<MEDIA_ID>?fields=media_url,thumbnail_url`, and use the new link it returns.

### Does thumbnail_url expire as well?

Assume it does. Check it for an `oe` parameter exactly as you check `media_url`.

### Why do images load locally but fail in production?

Locally the API is usually called on each request. In production a build, an ISR window or a CDN cache keeps serving links that were valid when the page was generated.

### Does the ElectricBlaze widget avoid expired images?

It requests a freshly signed data address on each page load, so the post list is never frozen in your HTML. Whether images of connected accounts are Instagram links or stored copies is not established yet.

## Related pages

- [Static HTML sites](https://electricblaze.com/developer/instagram-feed/static-site.html)
- [Next.js](https://electricblaze.com/developer/instagram-feed/nextjs.html)
- [Media fields](https://electricblaze.com/developer/instagram-feed/media-fields.html)
- [Access tokens](https://electricblaze.com/developer/instagram-feed/access-token.html)
- [Compare feed APIs](https://electricblaze.com/developer/instagram-feed/comparison.html)
- [Feed not working](https://electricblaze.com/developer/instagram-feed/feed-not-working.html)

---

ElectricBlaze, updated 2026-09-18. Source of this page: https://electricblaze.com/developer/instagram-feed/media-url-expired.html
