# Instagram long-lived access token: exchange, refresh, schedule and store it

**Status, checked 2026-09-18:** a short-lived Instagram User token expires in 1 hour and a long-lived one lasts 60 days ([Meta: access_token reference](https://developers.facebook.com/docs/instagram-platform/reference/access_token), checked 2026-09-18); `refresh_access_token` returns a new 60-day token only while the current one is at least 24 hours old and unexpired. The ElectricBlaze widget puts no Instagram token in the site's code.

> Instagram Login hands your server a token that dies within the hour. Here is how it becomes a 60-day token, how `ig_refresh_token` keeps it alive, and what to do on the day it lapses.

Canonical: https://electricblaze.com/developer/instagram-feed/access-token.html

## At a glance

- **Use it when:** your server, build step or CI job calls `graph.instagram.com` with a token for a Business or Creator account, and the feed has to survive past day 60.
- **Do not use it when:** the site only displays posts and nobody wants to own a Meta app, a secret store and a scheduled job. The widget described at the end of this page needs none of the three.
- **What the human does:** the account owner logs in once through Instagram Login and grants `instagram_business_basic`. Nobody touches the token again unless a refresh is missed; then the owner logs in a second time.
- **Next step:** copy `lib/instagram-token.mjs`, run the exchange once on your server, and schedule `scripts/refresh-instagram-token.mjs` for the 1st of every month.

## The token lifecycle

A token passes through four stages. Every call that moves it forward runs on a server or in CI, never in a browser.

| Stage | How you get it | Valid for | Where it runs |
|---|---|---|---|
| Short-lived token | Instagram Login, after the owner approves the app; your OAuth callback obtains it on the server | 1 hour | Server route that handles the login redirect |
| Long-lived token | `GET /access_token` with `grant_type=ig_exchange_token` and the app secret | 60 days | Server only: the URL carries `client_secret` |
| Refreshed token | `GET /refresh_access_token` with `grant_type=ig_refresh_token` | 60 days, counted from the refresh | Server, cron or CI, once the token is 24 hours old |
| Expired token | No call revives it | Nothing | The owner repeats Instagram Login |

Sources: [Meta: access_token reference](https://developers.facebook.com/docs/instagram-platform/reference/access_token), checked 2026-09-18 (lifetimes, exchange); [Meta: refresh_access_token reference](https://developers.facebook.com/docs/instagram-platform/reference/refresh_access_token), checked 2026-09-18 (refresh conditions).

Both token endpoints live on `graph.instagram.com` without a version segment ([Meta: access_token reference](https://developers.facebook.com/docs/instagram-platform/reference/access_token), checked 2026-09-18). Data calls such as `/me/media` do take one: Meta's Instagram examples use `v25.0`, while the newest Graph API release is v26.0 from 2026-07-29 ([Meta: Graph API changelog](https://developers.facebook.com/docs/graph-api/changelog), checked 2026-09-18). Pin the version you tested instead of chasing the latest.

## Step 1: exchange the short-lived token

Exchange right after login: the short-lived token is gone 1 hour after it was issued, and the exchange URL carries the app secret, so the call belongs in a server route, a serverless function or a one-off script ([Meta: access_token reference](https://developers.facebook.com/docs/instagram-platform/reference/access_token), checked 2026-09-18).

lib/instagram-token.mjs:

```js
// Server only: this module reads the app secret. Node 18+ (global fetch).
const GRAPH = "https://graph.instagram.com";

async function tokenCall(path, params) {
  const url = new URL(path, GRAPH);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url); // never log `url`: it contains the token
  const body = await res.json();
  if (!res.ok || !body.access_token) {
    const e = body.error ?? {};
    throw new Error(`${path} failed: HTTP ${res.status}, code ${e.code}, subcode ${e.error_subcode}: ${e.message}`);
  }
  // expires_in is in seconds; store the absolute date next to the token.
  return { token: body.access_token, expiresAt: new Date(Date.now() + body.expires_in * 1000) };
}

// Short-lived (1 hour) -> long-lived (60 days). Run once, right after login.
export function exchangeToken(shortLivedToken) {
  return tokenCall("/access_token", {
    grant_type: "ig_exchange_token",
    client_secret: process.env.INSTAGRAM_APP_SECRET,
    access_token: shortLivedToken,
  });
}

// Long-lived -> a new token valid for 60 days from now.
// Meta accepts it only if the token is at least 24 hours old and not expired.
export function refreshToken(longLivedToken) {
  return tokenCall("/refresh_access_token", {
    grant_type: "ig_refresh_token",
    access_token: longLivedToken,
  });
}
```

Keep `expiresAt` in the same record as the token, so a health check can warn two weeks before the deadline.

## Step 2: refresh before day 60

A refresh trades the current long-lived token for one that is valid for another 60 days, counted from the moment of the call. Meta accepts it under three conditions ([Meta: refresh_access_token reference](https://developers.facebook.com/docs/instagram-platform/reference/refresh_access_token), checked 2026-09-18):

- The token is at least 24 hours old. A job that fires minutes after the exchange gets an error.
- The token has not expired. Past day 60 there is nothing left to refresh.
- The owner granted `instagram_business_basic` at login. Authorize URLs that still ask for the retired value `business_basic`, deprecated on 2025-01-27, need updating ([Meta: Instagram API with Instagram Login](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login), checked 2026-09-18).

Response from `refresh_access_token` (example values):

```json
{
  "access_token": "EXAMPLE_LONG_LIVED_TOKEN",
  "token_type": "bearer",
  "expires_in": 5183944
}
```

`expires_in` counts seconds: 5,183,944 seconds is just under 60 days. Save the returned `access_token` as the current value even if it looks identical to the old one, and overwrite the stored expiry.

scripts/refresh-instagram-token.mjs:

```js
// Prints ONLY the new token on stdout, so a scheduler can pipe it into a secret store.
// Messages for humans go to stderr and never contain the token.
import { refreshToken } from "../lib/instagram-token.mjs";

const current = process.env.IG_ACCESS_TOKEN;
if (!current) {
  console.error("IG_ACCESS_TOKEN is not set");
  process.exit(1);
}

const { token, expiresAt } = await refreshToken(current); // throws -> exit code 1
process.stdout.write(token);
console.error(`Refreshed. New token expires around ${expiresAt.toISOString()}.`);
```

## Step 3: put the refresh on a schedule

Refresh every 30 to 50 days. A run on the 1st of each month keeps the gap at 31 days or less, which leaves about four weeks to notice a failed run and fix it before the token lapses. Refreshing more often is allowed after the first 24 hours, but it only adds runs that can fail.

### GitHub Actions

This fits a static site that builds in Actions and reads the token from a repository secret at build time. The workflow refreshes the token and writes it back into the same secret.

.github/workflows/refresh-instagram-token.yml:

```yaml
name: Refresh Instagram token
on:
  schedule:
    - cron: "23 5 1 * *" # 05:23 UTC on the 1st of each month
  workflow_dispatch: # manual run, useful for the first test
permissions:
  contents: read
jobs:
  refresh:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 22
      - name: Refresh the token and store it as a repository secret
        env:
          IG_ACCESS_TOKEN: ${{ secrets.IG_ACCESS_TOKEN }}
          GH_TOKEN: ${{ secrets.SECRETS_WRITE_PAT }}
        run: |
          node scripts/refresh-instagram-token.mjs > "$RUNNER_TEMP/ig-token"
          gh secret set IG_ACCESS_TOKEN < "$RUNNER_TEMP/ig-token"
```

- The built-in `GITHUB_TOKEN` cannot write repository secrets. Create a fine-grained personal access token for this repository with the **Secrets** permission set to read and write, and save it as `SECRETS_WRITE_PAT`.
- If the script fails, the step stops before `gh secret set`, so a failed refresh never blanks the secret.
- In a public repository GitHub disables scheduled workflows after 60 days without repository activity ([GitHub: events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule), checked 2026-09-18). A quiet repository can lose its refresh job, so check the Actions tab or monitor the stored expiry from somewhere else.

### Vercel Cron

On Vercel an environment variable is fixed when the deployment is built, so the refreshed token needs a writable home: a KV entry or a database row that server code reads at request time. A cron entry calls a route handler once a month.

vercel.json:

```json
{
  "crons": [{ "path": "/api/cron/instagram-token", "schedule": "23 5 1 * *" }]
}
```

app/api/cron/instagram-token/route.js:

```js
import { refreshToken } from "@/lib/instagram-token.mjs";
import { readToken, saveToken } from "@/lib/token-store"; // your KV, database or secret manager

export async function GET(request) {
  const secret = process.env.CRON_SECRET;
  if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) {
    return new Response("Unauthorized", { status: 401 });
  }
  const { token, expiresAt } = await refreshToken(await readToken());
  await saveToken(token, expiresAt);
  return Response.json({ ok: true, expiresAt }); // no token in the response
}
```

Vercel sends `Authorization: Bearer` plus the value of `CRON_SECRET` when that variable is set, does not retry a failed invocation, and on the Hobby plan may run the job at any minute inside the scheduled hour ([Vercel: managing cron jobs](https://vercel.com/docs/cron-jobs/manage-cron-jobs), checked 2026-09-18). A monthly schedule is within the limits of every plan.

Any scheduler that runs Node works the same way, from a crontab line to a Cloudflare Workers cron trigger: one refresh a month, plus an alert when it fails.

## Where the token lives

- **Server side only.** An environment variable read by server code, a secret manager or a database row. With `instagram_business_basic` the token reads the profile and its media ([Meta: Instagram API with Instagram Login](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login), checked 2026-09-18), so handle it like a password.
- **Never behind a public prefix.** Variables named `NEXT_PUBLIC_*`, `VITE_*` or Astro's `PUBLIC_*` are inlined into the JavaScript every visitor downloads. Call it `IG_ACCESS_TOKEN` and read it only in server code or at build time.
- **Writable by the refresh job.** The store must accept the new value from the job. A variable baked into a deployment only works if the job also rewrites it and triggers a rebuild, as the GitHub workflow above does.
- **Out of logs.** The token travels as a query parameter, so never print request URLs from these calls. Log the expiry date instead, and keep `.env.local` out of git.

A token fixes access, not image links. `media_url` values expire on their own schedule; see [media URL expired](https://electricblaze.com/developer/instagram-feed/media-url-expired.html) before caching pages built from them.

## When the token has already expired

Calls with a lapsed token fail with `OAuthException` code 190 and `error_subcode` 463; a changed password gives subcode 460 ([Meta: Graph API error handling](https://developers.facebook.com/docs/graph-api/guides/error-handling), checked 2026-09-18). The refresh endpoint rejects an expired token as well ([Meta: refresh_access_token reference](https://developers.facebook.com/docs/instagram-platform/reference/refresh_access_token), checked 2026-09-18), so recovery always goes through the account owner:

1. The owner goes through Instagram Login again and approves the app.
2. Your callback exchanges the new short-lived token within the hour.
3. You store the result and find out why the schedule missed: a disabled workflow, an unretried cron run, a store the job could not write to.
4. You add an alert for "fewer than 14 days left", computed from the stored expiry.

Other 190 subcodes and the errors that look similar are listed on [feed not working](https://electricblaze.com/developer/instagram-feed/feed-not-working.html).

## Skipping the token: the ElectricBlaze widget

When the goal is a grid of posts on a website, the token work can leave the project. With the ElectricBlaze widget the account owner connects Instagram at [a.electricblaze.com](https://a.electricblaze.com/), and the site embeds a snippet with no Meta app and no token behind it:

```html
<div class="electricblaze-id-WIDGET_ID"></div>
<script src="https://s.electricblaze.com/widget.js" defer></script>
```

The limits, stated plainly. The widget renders in the browser with layouts chosen in the dashboard, and its feed has no carousel `children` and no `media_product_type`. The Free plan covers 1000 views a month ([pricing](https://electricblaze.com/pricing.html)). A JSON API for server code is planned for 0.2 of the `electricblaze` CLI; until then, code that needs raw fields keeps its own token and follows the steps above.

## FAQ

### How long does an Instagram access token last in 2026?

A short-lived Instagram User token lasts 1 hour. The long-lived token from `ig_exchange_token` lasts 60 days, and every refresh starts a new 60-day period ([Meta: access_token reference](https://developers.facebook.com/docs/instagram-platform/reference/access_token), checked 2026-09-18).

### Can I refresh an expired Instagram token?

No. `refresh_access_token` only accepts a token that is at least 24 hours old and still valid. After expiry the account owner has to log in again.

### Does the refresh call need the app secret?

No. The refresh URL carries only `grant_type=ig_refresh_token` and the current token. The app secret appears once, in the exchange, which is why that call has to stay on a server.

### How often should I refresh the token?

Once a month. That keeps the gap well under 60 days and leaves time to repair a failed run. A token younger than 24 hours cannot be refreshed.

### Can I store the token in a NEXT_PUBLIC_ or VITE_ variable?

No. Those prefixes copy the value into the client bundle, where any visitor can read it. Read the token only in server code, a build step or a serverless function.

### Can I show an Instagram feed without managing a token at all?

Yes, with an embedded widget whose provider holds the connection. The ElectricBlaze widget is one option; the site gets a two-line snippet.

## Related pages

- [Feed not working](https://electricblaze.com/developer/instagram-feed/feed-not-working.html)
- [App Review](https://electricblaze.com/developer/instagram-feed/app-review.html)
- [Media URL expired](https://electricblaze.com/developer/instagram-feed/media-url-expired.html)
- [Basic Display alternative](https://electricblaze.com/developer/instagram-feed/basic-display-api-alternative.html)
- [Next.js](https://electricblaze.com/developer/instagram-feed/nextjs.html)
- [Instagram feed for developers](https://electricblaze.com/developer/instagram-feed/)

---

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