Twilio JWT Token Expired: Every Code and How to Fix It
Twilio JWT token expired spans 31205, 20104 and eight more codes. What each one means, why tokens die at one hour, and the refresh loop that fixes it.
Twilio JWT token expired spans 31205, 20104 and eight more codes. What each one means, why tokens die at one hour, and the refresh loop that fixes it.
"Twilio JWT token expired" is a family of errors, and the two you will see most are 31205 in the Voice SDK and 20104 from the REST API. Both mean the same thing: the exp claim in your access token points at a time that has already passed, so Twilio rejects it. Access tokens default to a one-hour lifetime in every Twilio helper library and can never exceed 24 hours, which makes this failure scheduled rather than random — it lands one TTL after your page loaded. This guide maps all ten codes in the family to their real causes, then shows the refresh loop that stops them.
A Twilio access token is a short-lived JWT your server mints with an API Key SID and its secret. It carries an identity, a set of grants (a VoiceGrant for the Voice SDK), and an expiry. Twilio revalidates the signature, issuer, subject, and expiry on every use, so the moment exp passes, everything that token authorized stops working.
Twilio's 31205 documentation describes the effect precisely: signaling, DTMF, and Insights all stop until a fresh token is supplied. The default lifetime is 3,600 seconds — that value is hardcoded as the fallback in the helper libraries themselves (this.ttl = options.ttl || 3600 in twilio-node's AccessToken, ttl=3600 in twilio-python), which is why so many teams find their dialer dies almost exactly 60 minutes after page load.
The fix is never "make the token last longer." It is a refresh loop, and the rest of this page is about building one that survives the edge cases.
Ten codes cluster around this problem, split across two layers of Twilio's stack. Find yours here before changing any code:
| Code | Emitted by | What it actually means | What to change |
|---|---|---|---|
| 31205 | Voice SDK | The token's lifetime elapsed mid-session | Refresh via device.updateToken() before expiry |
| 31204 | Voice SDK | The JWT can't be parsed, or is missing identity / VoiceGrant | Regenerate with a helper library; Restricted API Keys are not supported |
| 31202 | Voice SDK | Signature didn't validate against the secret for the key in iss | Sign with the secret matching that API Key SID; algorithm must be HS256 |
| 31207 | Voice SDK | Expiration interval longer than 24 hours | Set ttl to 86,399 seconds or less |
| 20101 | REST API | Not a structurally valid access token at all | Check the header (typ, alg, cty) and payload fields |
| 20103 | REST API | Bad issuer or subject: wrong Account SID, deleted API Key, suspended account | Verify live credentials and that the key belongs to that account |
| 20104 | REST API | exp is in the past, malformed, or beyond the 24-hour maximum | Regenerate server-side and refresh before expiry |
| 20107 | REST API | Signature invalid — wrong Account SID, API Key, or secret | Regenerate the key and secret, update server config |
| 20156 | REST API | exp expired or outside the allowed clock skew | Sync the token-issuing server's clock over NTP |
| 20157 | REST API | exp exceeds the 24-hour maximum | Pass ttl in seconds (3600), never milliseconds |
Only four of those ten are genuinely about time. The rest are credential and structure problems that happen to surface through the same authorization path, which is why "my token expired" turns out so often to be "my token was never valid." If you are seeing 31201 instead, that is the unspecified authorization error and has its own set of causes. If the token validates cleanly and the connection is still refused, you are looking at 31002 connection declined, not a token problem.
The 312xx codes come from the Voice SDK — JavaScript, iOS, or Android — and surface in your browser console through device.on('error'). The 201xx codes come from Twilio's platform and REST layer, and surface in the Console Debugger under Monitor → Logs → Errors.
Same token, two validators, two vantage points. A token that fails in the browser with 31204 can leave no trace in the Debugger at all, because it never reached the REST layer. The reverse happens too: a server-side API call rejects with 20107 while the browser stays connected on a token minted minutes earlier with the old secret.
The layers do cross. Calling device.updateToken() with an already-expired token raises AccessTokenExpired (20104) inside the SDK — a platform code delivered through the client, documented in twilio-voice.js issue #93. Read both surfaces before concluding anything.
One hour by default, 24 hours at the absolute maximum. Twilio's access token documentation states that "all tokens have a limited lifetime, configurable for up to 24 hours" and recommends generating them "for the shortest amount of time feasible."
Here is a minimal Node endpoint with the TTL made explicit:
const { jwt: { AccessToken } } = require('twilio');
const { VoiceGrant } = AccessToken;
app.get('/api/voice-token', (req, res) => {
const ttl = 3600; // seconds, not milliseconds
const token = new AccessToken(
process.env.TWILIO_ACCOUNT_SID, // AC...
process.env.TWILIO_API_KEY_SID, // SK...
process.env.TWILIO_API_KEY_SECRET,
{ identity: `agent-${req.user.id}`, ttl },
);
token.addGrant(new VoiceGrant({
outgoingApplicationSid: process.env.TWILIO_TWIML_APP_SID,
incomingAllow: true,
}));
res.json({
token: token.toJwt(),
expiresAt: Date.now() + ttl * 1000, // let the client schedule its own refresh
});
});
Two mistakes live in that one ttl line. Passing milliseconds (3600000) blows straight through the ceiling and returns 20157, which lists the milliseconds mix-up as a named cause. Setting the ceiling deliberately — 86,400 — trips it too; Twilio's 31207 page tells you to stay at 86,399 or below.
Raising the TTL to 24 hours is the tempting fix and the wrong one. It widens the window in which a leaked token stays usable, and any session longer than a day still breaks. Build the refresh instead.
The Voice JS SDK gives you an event and a method. The five-line version everyone posts:
device.on('tokenWillExpire', async () => {
const { token } = await fetch('/api/voice-token').then(r => r.json());
device.updateToken(token);
});
That works, and then three things about it bite in production.
The default warning window is ten seconds. The tokenRefreshMs option controls how far ahead of expiry tokenWillExpire fires, and it defaults to 10000 — ten seconds, as set in the SDK's own device.ts. If your token endpoint cold-starts, sits behind an auth redirect, or the user's connection is slow, ten seconds is not a comfortable budget for a round trip. Widen it when you construct the Device:
const device = new Device(token, { tokenRefreshMs: 120000 }); // 2 minutes
The timer arms once per signaling connection. Reading the SDK source, the refresh timeout is created inside the handler for the signaling layer's connected event, using the TTL that signaling reports, and it is cleared immediately after the event is emitted. Developers have reported tokenWillExpire firing only once per session as a result (that is the substance of issue #93). Do not make it your only trigger — keep an independent timer driven by the expiresAt your server already returns:
function scheduleRefresh(expiresAt) {
const lead = 120_000; // refresh 2 minutes early
const delay = Math.max(0, expiresAt - Date.now() - lead);
setTimeout(async () => {
const { token, expiresAt: next } = await fetchToken();
device.updateToken(token);
scheduleRefresh(next);
}, delay);
}
Background tabs throttle your timer. Chrome throttles setTimeout in hidden tabs, so a refresh scheduled 58 minutes out can fire late in a tab the user left behind three other windows — and it fires after the token is already dead. Re-check the remaining lifetime whenever the tab comes back:
document.addEventListener('visibilitychange', () => {
if (!document.hidden && expiresAt - Date.now() < 120_000) refreshNow();
});
One last rule: never call updateToken() with a token you have been holding. Mint it, use it immediately, and let the server decide the expiry.
If a token you minted sixty seconds ago comes back as 31205 or 20156, stop reading your token code and check your server's clock.
The oldest and most-linked Stack Overflow thread on this error — 1,872 views since 2013 — ends with the author finding nothing wrong in the code at all. The accepted answer is his own: "The problem was my Virtual Machine that had the wrong date/time settings." Twilio's 20156 page names the same cause: a token-generating system with an incorrect clock produces an exp that evaluates as expired or falls outside the permitted skew.
Check it in one line:
date -u && curl -sI https://api.twilio.com | grep -i '^date:'
More than a few seconds of disagreement between those two timestamps and the clock is your bug. On Linux, timedatectl status should report NTP service: active and System clock synchronized: yes. Containers inherit the host clock, so fix the host. Laptops and VMs resumed from sleep are the usual offenders — which is why this reproduces on a developer machine and never in staging.
Skew cuts the other way too. The helper libraries set an nbf ("not before") claim, so a clock running fast produces a token Twilio considers not yet valid, and you get an authorization failure on a token that has 59 minutes of life left.
The call keeps going. New calls stop.
A Twilio employee answered this directly on a Stack Overflow thread about 31204 and 31205 appearing during live WebRTC sessions: "Tokens expiring shouldn't be a problem for active calls. You should still be able to continue the call, but you won't be able to initiate a new call until you generate a new token."
The person who filed that thread describes the other half of the symptom — the errors repeat "hundreds of times while the call is still live" as the SDK keeps retrying its signaling authorization. So the failure looks like this from a user's seat: the conversation they are having sounds perfect, the console fills with 31205, and the device has quietly stopped accepting incoming calls. Registration lapses even though the media path survives.
That combination is worth recognizing on a support ticket. "Calls stopped arriving after about an hour, but the one I was on was fine" is a token expiry, while "calls stopped arriving and the console says nothing" is usually a device that never registered or went offline.
Paste it into jwt.io, or read the payload locally without sending your credentials anywhere:
node -e "console.log(JSON.parse(Buffer.from(process.argv[1].split('.')[1],'base64')))" "$TOKEN"
Five things to look at, in the order that resolves the most tickets:
exp minus iat is your real TTL in seconds. Expect 3600 unless you set it. Anything above 86,400 explains 31207 and 20157.iss must be your API Key SID, starting SK. If it starts AC, you signed with the Account SID and Auth Token instead of an API key — that produces 31202 and 20107.sub must be the Account SID, starting AC, and must be the account that owns the key. A mismatch, a deleted key, or a suspended account gives you 20103.grants must contain a voice object for the Voice SDK, alongside a non-empty identity. Missing either produces 31204 rather than an expiry code.If all five are right and the token still fails, you are back to the clock.
How long does a Twilio Auth Token last? It does not expire. The Auth Token is your permanent account credential, shown in the Console next to your Account SID, and it is a different thing from an access token. Access tokens are the short-lived JWTs that expire in one hour by default. Never ship an Auth Token to a browser.
Why do JWT tokens expire at all?
Because a client-side token is exposed by definition — it lives in browser memory where anyone with devtools can copy it. A short lifetime caps how long a stolen token is useful. Twilio enforces this with a hard 24-hour ceiling that no ttl value can exceed.
Can an expired token cause Twilio error 31000? Yes. Twilio engineers have confirmed that a token expiring while a tab sits idle sometimes surfaces as the generic 31000 error rather than 31205. If a bare 31000 lands roughly one hour after page load, treat the token as your first suspect.
Which backend language should mint Twilio access tokens?
Any of them — Twilio ships helper libraries for Node, Python, Java, C#, PHP, Ruby, and Go, all with the same AccessToken plus VoiceGrant shape and the same 3600-second default. The only hard rule is that minting happens server-side, because the API key secret must never reach the client.
Do capability tokens have the same expiry problem?
They do, and worse. ClientCapability tokens are the pre-2017 mechanism signed with your Auth Token, still present in the helper libraries, and they have no tokenWillExpire event — expiry means rebuilding the Device. Migrate to AccessToken with API keys if you are still on them.
Token TTLs, refresh timers, clock sync, and a ten-second default warning window are infrastructure you build once and then maintain forever. Alloqui takes it off your list: you store your Twilio API key and secret, our backend mints short-lived tokens with the correct grants for every session, and the <Dialer /> component refreshes them through device.updateToken() well before expiry. Signing mistakes become structurally impossible because we build the grant server-side. Paste your keys, drop in the component.