At a glance
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, 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.
- Use it when
- your server, build step or CI job calls
graph.instagram.comwith 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 schedulescripts/refresh-instagram-token.mjsfor 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, checked 2026-09-18 (lifetimes, exchange); Meta: refresh_access_token reference, checked 2026-09-18 (refresh conditions).
Both token endpoints live on graph.instagram.com without a version segment (Meta: access_token reference, 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, 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, checked 2026-09-18).
lib/instagram-token.mjs
// 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, 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_basicat login. Authorize URLs that still ask for the retired valuebusiness_basic, deprecated on 2025-01-27, need updating (Meta: Instagram API with Instagram Login, checked 2026-09-18).
Response from refresh_access_token (example values)
{
"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
// 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
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_TOKENcannot 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 asSECRETS_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, 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
{
"crons": [{ "path": "/api/cron/instagram-token", "schedule": "23 5 1 * *" }]
}
app/api/cron/instagram-token/route.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, 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_basicthe token reads the profile and its media (Meta: 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'sPUBLIC_*are inlined into the JavaScript every visitor downloads. Call itIG_ACCESS_TOKENand 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.localout of git.
A token fixes access, not image links. media_url values expire on their own schedule; see media URL expired 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, checked 2026-09-18). The refresh endpoint rejects an expired token as well (Meta: refresh_access_token reference, checked 2026-09-18), so recovery always goes through the account owner:
- The owner goes through Instagram Login again and approves the app.
- Your callback exchanges the new short-lived token within the hour.
- 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.
- 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.
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, and the site embeds a snippet with no Meta app and no token behind it:
<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). 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.