Back to Blog
Engineering

Instagram Profile Picture URL Expired? How to Save Social Media Images Properly

October 20, 2026
9 min read
S
By SociaVault Team
profile picturesInstagramTikTokThreadsimage storageCDN

Instagram Profile Picture URL Expired? How to Save Social Media Images Properly

You built a creator directory. Each row has a name, a follower count and a profile picture, loaded straight from the URL the API gave you. It looked great on Monday. By Friday half the avatars are broken image icons, and the console is full of 403s.

Nothing's wrong with your code. Those URLs were never meant to last. Instagram, TikTok and Threads hand out image links that expire, and if you store the link instead of the image, you're storing a countdown.

We pulled profile pictures from each platform to find out exactly how long you get, and what happens if you try to cheat the expiry.

Which platforms expire, and how fast

PlatformSigned URL?Expires inSame URL on every call?
InstagramYesAbout 4.5 daysNo
ThreadsYesAbout 4.5 daysNo
TikTokYesAbout 2 daysNo
X (Twitter)NoDoesn't expireYes
YouTubeNoDoesn't expireYes

The expiry is written right into the URL, if you know where to look.

Instagram and Threads use a parameter called oe. It's a Unix timestamp in hexadecimal. The Instagram avatar we pulled had oe=6AC401A9, which decodes to 5 October 2026 at 19:59 UTC, about 107 hours after we fetched it.

TikTok uses x-expires, a plain Unix timestamp. Ours said 3 October at 09:00 UTC, about 48 hours out.

You can read the expiry in one line:

const url = new URL(avatarUrl);
const oe = url.searchParams.get("oe"); // Instagram, Threads (hex)
const xe = url.searchParams.get("x-expires"); // TikTok (decimal)
const expiresAt = oe ? parseInt(oe, 16) * 1000 : xe ? Number(xe) * 1000 : null;
console.log(expiresAt ? new Date(expiresAt).toISOString() : "no expiry");

Can you just change the expiry?

We tried, so you don't have to.

On Instagram, we pushed the oe date a month into the future. The CDN returned a 403. The expiry is covered by a signature, and changing it breaks the signature. On TikTok, removing x-expires entirely got a 403, and so did changing a single character of x-signature. On Threads, changing one character of the oh signature got a 403.

One oddity: on Instagram, changing a character of oh on its own still loaded the image when we tried it. Don't read anything into that. The expiry was still enforced, and whatever's lenient today can be tightened tomorrow. There's no supported way to extend these links.

Why the URL changes every time

Fetch the same Instagram profile twice and you'll get two different picture URLs. In our test, four parameters changed between calls (_nc_ohc, _nc_oc, _nc_ht and oh), while the expiry stayed the same.

This breaks a common shortcut: "only download the picture if the URL changed." On Instagram, TikTok and Threads, the URL changes even when the picture hasn't. You'd re-download everything, every time. Compare the image itself instead, using a hash of the bytes.

The fix: store the bytes, key on the ID

The pattern that holds up:

  1. Fetch the profile and read the picture URL.
  2. Download the image right away, while the URL is fresh.
  3. Hash the bytes. If the hash matches what you stored last time, the picture hasn't changed.
  4. Save the file under the account's stable ID, not its handle, so a rename doesn't orphan it.
  5. Serve the image from your own storage. Never hotlink the platform's URL.

Here's where each platform keeps the picture and the ID in our responses:

PlatformEndpointPictureStable ID
Instagram/instagram/profile?handle=data.data.user.profile_pic_url_hddata.data.user.id
TikTok/tiktok/profile?handle=data.user.avatarLargerdata.user.id
Threads/threads/profile?handle=data.profile_pic_urldata.pk
X/twitter/profile?handle=data.legacy.profile_image_url_httpsdata.rest_id
YouTube/youtube/channel?handle=first url in data.avatar.image.sourcesdata.channelId

Paths drift, so the code reads them defensively:

import crypto from "node:crypto";
import fs from "node:fs/promises";

const API = "https://api.sociavault.com/v1/scrape";
const KEY = process.env.SOCIAVAULT_API_KEY;
const first = (x) =>
  Array.isArray(x) ? x[0] : x ? Object.values(x)[0] : undefined;

const SOURCES = {
  instagram: {
    path: "instagram/profile",
    pick: (d) => {
      const u = d?.data?.user ?? d?.user;
      return { id: u?.id, url: u?.profile_pic_url_hd ?? u?.profile_pic_url };
    },
  },
  tiktok: {
    path: "tiktok/profile",
    pick: (d) => ({ id: d?.user?.id, url: d?.user?.avatarLarger }),
  },
  threads: {
    path: "threads/profile",
    pick: (d) => ({ id: d?.pk ?? d?.id, url: d?.profile_pic_url }),
  },
  twitter: {
    path: "twitter/profile",
    pick: (d) => ({
      id: d?.rest_id,
      // "_normal" is 48px; "_400x400" is the useful size
      url: d?.legacy?.profile_image_url_https?.replace("_normal", "_400x400"),
    }),
  },
  youtube: {
    path: "youtube/channel",
    pick: (d) => ({
      id: d?.channelId,
      url: first(d?.avatar?.image?.sources)?.url?.replace(/=s\d+/, "=s800"),
    }),
  },
};

async function saveAvatar(platform, handle, lastHash) {
  const src = SOURCES[platform];
  const res = await fetch(
    `${API}/${src.path}?handle=${encodeURIComponent(handle)}`,
    {
      headers: { "x-api-key": KEY },
    },
  );
  const body = await res.json();
  if (body?.error) return { platform, handle, error: body.error };

  const { id, url } = src.pick(body.data);
  if (!id || !url) return { platform, handle, error: "no picture in response" };

  const img = Buffer.from(await (await fetch(url)).arrayBuffer()); // download now
  const hash = crypto.createHash("sha256").update(img).digest("hex");
  if (hash === lastHash) return { platform, id, hash, changed: false };

  await fs.mkdir(`avatars/${platform}`, { recursive: true });
  await fs.writeFile(`avatars/${platform}/${id}.jpg`, img);
  return { platform, id, hash, changed: true };
}

In Python, the same idea:

import hashlib, os, re, requests

API = "https://api.sociavault.com/v1/scrape"
HEADERS = {"x-api-key": os.environ["SOCIAVAULT_API_KEY"]}

def first(x):
    return x[0] if isinstance(x, list) and x else next(iter((x or {}).values()), None)

def pick(platform, d):
    if platform == "instagram":
        u = (d.get("data") or {}).get("user") or d.get("user") or {}
        return u.get("id"), u.get("profile_pic_url_hd") or u.get("profile_pic_url")
    if platform == "tiktok":
        u = d.get("user") or {}
        return u.get("id"), u.get("avatarLarger")
    if platform == "threads":
        return d.get("pk") or d.get("id"), d.get("profile_pic_url")
    if platform == "twitter":
        url = ((d.get("legacy") or {}).get("profile_image_url_https") or "").replace("_normal", "_400x400")
        return d.get("rest_id"), url or None
    if platform == "youtube":
        src = first(((d.get("avatar") or {}).get("image") or {}).get("sources")) or {}
        return d.get("channelId"), re.sub(r"=s\d+", "=s800", src.get("url", "")) or None

PATHS = {"instagram": "instagram/profile", "tiktok": "tiktok/profile", "threads": "threads/profile",
         "twitter": "twitter/profile", "youtube": "youtube/channel"}

def save_avatar(platform, handle, last_hash=None):
    body = requests.get(f"{API}/{PATHS[platform]}", params={"handle": handle}, headers=HEADERS, timeout=60).json()
    if body.get("error"):
        return {"error": body["error"]}
    pid, url = pick(platform, body.get("data") or {})
    if not pid or not url:
        return {"error": "no picture in response"}
    img = requests.get(url, timeout=60).content
    digest = hashlib.sha256(img).hexdigest()
    if digest != last_hash:
        os.makedirs(f"avatars/{platform}", exist_ok=True)
        with open(f"avatars/{platform}/{pid}.jpg", "wb") as f:
            f.write(img)
    return {"id": pid, "hash": digest, "changed": digest != last_hash}

Getting a bigger image

The default isn't always the best size you can get.

X gives you _normal by default, which is 48 pixels and about 2KB. Swap it for _400x400 and we got a 13KB image. Remove the suffix entirely and you get the original upload (18KB in our test). All of these loaded with no signature involved.

YouTube puts the size in the URL as =s68. Change it to =s176 or =s800 to ask for more. You won't get more than the channel uploaded, though: in our test =s176 and =s800 returned the same file, because the source wasn't any bigger.

Instagram has both profile_pic_url and profile_pic_url_hd. Use the HD one. TikTok has avatarThumb, avatarMedium and avatarLarger. Use the large one.

How often to refresh

Profile pictures don't change often. For most uses, checking weekly is plenty, and the hash check means a refresh only writes a file when the picture actually changed. If you're showing creators in a live product and it matters that a new picture appears quickly, daily is the practical ceiling. Anything more frequent spends credits re-confirming pictures that haven't moved.

We cover the same expiry problem for videos, with a full storage pipeline, in archiving TikTok videos to S3. And if you're refreshing many profiles on a schedule, caching and rate-limiting to save credits is worth a read.

The honest limits

  • Public profiles only. Private accounts don't expose a picture to fetch.
  • Expiry windows aren't a contract. We measured about 4.5 days for Instagram and Threads and about 2 days for TikTok. Platforms can shorten them without notice, so download immediately rather than relying on the window.
  • Hashes catch any change, including recompression. If a platform re-encodes the same picture, the hash changes and you'll save a near-duplicate. Harmless, just worth knowing.
  • Check you're allowed to keep images. Storing someone's picture to display in your product carries its own rules. Make sure your use is one you'd be comfortable explaining.

Frequently Asked Questions

Why did my Instagram profile picture URL stop working?

Instagram's image links are signed and expire, usually after a few days. The URL carries an oe parameter with the expiry time. Once it passes, the CDN returns a 403.

How long do Instagram profile picture URLs last?

In our test, about 4.5 days from when we fetched it. The exact time is in the oe parameter, a hex Unix timestamp. Threads links behaved the same way.

Can I change the expiry in the URL?

No. The expiry is covered by a signature. When we pushed it into the future, the image returned a 403.

Do X (Twitter) profile picture URLs expire?

Not in our tests. X avatar URLs are unsigned and stayed the same across calls. You can also swap _normal for _400x400 to get a larger version.

Why does the profile picture URL change every time I fetch it?

Instagram, TikTok and Threads regenerate signed parameters on each request, even when the picture is the same. Compare a hash of the image bytes to detect real changes, not the URL.

What's the best way to store profile pictures?

Download the image as soon as you get the URL, save it under the account's stable ID, and serve it from your own storage. Re-check on a schedule and only overwrite when the hash changes.


Need avatars for a list of creators? Sign up for 50 free credits and run the script above against a few handles from each platform.

Found this helpful?

Share it with others who might benefit

Ready to Try SociaVault?

Start extracting social media data with our powerful API. No credit card required.