OAuth Token Expiry: How to Check if a Token Is Expired
April 28, 2026
Checking whether an OAuth access token is expired sounds simple. In production, it's where most token-handling bugs show up. There are three signals to use — the expires_at timestamp you compute when the token is issued, the exp claim inside a JWT, or a 401 response from the API — and each has different reliability characteristics. Treating any one of them as authoritative on its own creates race conditions, redundant refreshes, and "expired by the time the call lands" failures that are hard to debug.
This guide covers how to check token expiry across both JWT and opaque tokens, the patterns that handle expiry safely under concurrent load, and the specific implementation details (clock skew, refresh token rotation, single-flight locks) that separate reliable token handling from the fragile kind.
Key takeaways
- An OAuth access token can be a JWT (self-contained, decodable client-side via the
expclaim) or an opaque token (a reference string the authorization server validates server-side). Your application code should treat both as bearer tokens with an expiry; the validation mechanism differs. - Three signals indicate expiry: the local
expires_attimestamp computed when the token was issued, theexpclaim inside a JWT, and a 401 response from the API. The server's response is always authoritative — your local check is for refresh scheduling, not for trusting the token. - Refresh tokens come in two flavors: static (reuse the same token until it expires) and rotating (the server returns a new refresh token on every refresh and invalidates the old one). Your code must detect which pattern the authorization server uses and store the latest refresh token after every successful refresh.
- Production token handling requires a single-flight refresh pattern: only one refresh runs per connection at a time, all other in-flight requests wait for it. Without this, concurrent requests trigger duplicate refreshes, refresh tokens get overwritten, and tokens become invalid.
- Refresh tokens proactively before expiry (typically 5–10 minutes early) rather than waiting for 401s. Reactive 401-then-refresh works as a safety net, but as the primary trigger it introduces latency and edge cases where multiple requests fail simultaneously.
What does an OAuth access token actually contain?
OAuth 2.0 doesn't prescribe the format of an access token. The OAuth 2.0 specification (RFC 6749) leaves token format up to the authorization server. In practice, authorization servers issue tokens in one of two formats.
JWT access tokens
A JWT (JSON Web Token) is a signed JSON object that contains its claims directly: the subject, the granted scopes, the issuer, the audience, and the expiry timestamp (exp, in Unix seconds since epoch). Anyone with the public verification key can validate a JWT locally without calling the authorization server.
A typical JWT access token looks like three base64-encoded segments separated by dots:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0...exp...}.signature
The middle segment, decoded, contains the claims:
{
"sub": "user_12345",
"iss": "https://auth.example.com",
"aud": "your-app",
"exp": 1735689600,
"iat": 1735686000,
"scope": "read write"
}
exp is the expiry timestamp. iat is when the token was issued. The difference (exp - iat) gives you the token's lifetime — typically 1 hour for access tokens, but configurable per integration.
Opaque access tokens
An opaque token is a random-looking string with no meaningful structure. Your application can't decode it; only the authorization server knows what it represents. To check whether an opaque token is valid, you call the authorization server's introspection endpoint or rely on getting a 401 from the API.
Opaque tokens trade local validation convenience for stronger revocation control. The authorization server can invalidate a token at any moment and the change takes effect immediately (no waiting for exp to pass). This matters for compliance scenarios and for any application where instant revocation is a hard requirement.
Which format will you encounter?
Both. Authorization servers vary: some always issue JWTs, some always issue opaque tokens, some issue different formats depending on configuration. Your token-handling code should treat both formats as bearer tokens with an expiry — the way you check that expiry differs.
How do you check if a JWT access token is expired?
Two patterns: a debug pattern for inspecting tokens during development, and a production pattern that uses a JWT library to verify both the signature and the claims in a single call.
Debug pattern: decode and read exp
This is for inspection only — useful for understanding what a token contains, debugging an integration, or logging diagnostic information. It does not verify the signature.
JavaScript / TypeScript:
function decodeJwtPayload(token) {
const [, payloadB64] = token.split('.');
if (!payloadB64) throw new Error('Invalid JWT');
const json = Buffer.from(payloadB64, 'base64url').toString('utf8');
return JSON.parse(json);
}
function isJwtExpired(token, skewSeconds = 300) {
const payload = decodeJwtPayload(token);
const exp = typeof payload.exp === 'number' ? payload.exp : 0;
const now = Math.floor(Date.now() / 1000);
return now >= exp - skewSeconds;
}
Python:
import time
import jwt # PyJWT
def is_jwt_expired(token: str, skew_seconds: int = 300) -> bool:
payload = jwt.decode(token, options={"verify_signature": False})
exp = int(payload.get("exp", 0))
now = int(time.time())
return now >= exp - skew_seconds
Both functions return True if the token is within skew_seconds of its expiry time. The skew buffer (default 300 seconds = 5 minutes here) is what lets your code refresh proactively before the token actually expires.
Production pattern: verify signature and claims via a library
In production, never trust the contents of a JWT without verifying the signature. Use a JWT library that does both signature verification and exp enforcement in one call — and treat an "expired" exception as the canonical signal that you need to refresh.
Python with PyJWT:
import jwt
from jwt import ExpiredSignatureError, InvalidTokenError
def verify_access_token(token: str, key: str, algorithms: list[str]) -> dict:
try:
return jwt.decode(token, key=key, algorithms=algorithms)
except ExpiredSignatureError:
raise ValueError("access token expired")
except InvalidTokenError:
raise ValueError("access token invalid")
Node.js with jsonwebtoken:
const jwt = require('jsonwebtoken');
function verifyAccessToken(token, publicKey, options = {}) {
try {
return jwt.verify(token, publicKey, options);
} catch (err) {
if (err.name === 'TokenExpiredError') {
throw new Error('access token expired');
}
throw new Error('access token invalid');
}
}
The library enforces signature, exp, aud, iss, and other standard claims. You catch the expiry exception specifically and trigger your refresh flow.
How do you check if an opaque access token is expired?
You can't check opaque tokens locally — there's nothing to decode. Two options:
Track expires_at from the token response
When the authorization server issues the token, the response includes expires_in (lifetime in seconds). Compute the absolute expiry timestamp at issuance:
import time
token_response = exchange_authorization_code(code)
expires_at = int(time.time()) + token_response["expires_in"]
Store expires_at alongside the token. Before using the token, check whether now >= expires_at - skew. This is functionally identical to checking the exp claim of a JWT, just using the value the server returned instead of one embedded in the token.
Call the introspection endpoint
If the authorization server supports OAuth 2.0 token introspection (RFC 7662), you can ask it directly whether a token is currently valid:
POST /oauth/introspect
Content-Type: application/x-www-form-urlencoded
token=ACCESS_TOKEN_VALUE
&token_type_hint=access_token
The response includes active (boolean), exp, scope, and other metadata. Introspection is authoritative — if it says active: false, the token is invalid regardless of what your local timestamp says. The trade-off is one network round-trip per check.
In practice, most production code combines both: track expires_at for cheap local pre-checks (so you can refresh proactively), and treat 401 responses from the API as the authoritative signal that something is wrong.
Why isn't "check once, then call" enough?
A naive expiry check looks like this:
if token is expired:
refresh token
make API call with token
This works most of the time. It fails in three specific scenarios.
Clock skew between your application and the authorization server
Your server's clock and the authorization server's clock can drift apart by seconds or minutes. A token your local check considers valid (because expires_at - now > 0) may be considered expired by the authorization server. The fix is the skew buffer in your local check — refresh when now >= expires_at - skew_seconds, not when now >= expires_at. A 5-minute skew is conservative and works for most cases.
Race conditions on concurrent requests
Multiple in-flight requests can all see the same stale token at roughly the same time. Without coordination, each one independently triggers a refresh:
Request A: token stale → refresh → new token A
Request B: token stale → refresh → new token B (overwrites A)
Request C: token stale → refresh → new token C (overwrites B)
For static refresh tokens, this is wasteful but eventually consistent. For rotating refresh tokens (where the server invalidates the old refresh token on every successful refresh), this is catastrophic — multiple refresh calls with the same now-invalidated refresh token cause the entire token chain to be revoked, forcing the user to re-authenticate.
Token expiring during the request
The token can be valid when you check it locally and expired by the time the API call lands at the upstream server. This is unavoidable without a skew buffer; even with one, you can hit it occasionally. The only safe handling is to treat 401 responses as "token expired, refresh and retry once."
What's the right pattern for handling token expiry?
Three components, none of them optional in a production token-handling layer.
1. Centralized token state
All token data (access token, refresh token, expires_at, scopes) lives in one place — a database, a cache, or an in-memory singleton in a single-instance application. Every API call reads token state from this central location; no caller manages its own token cache.
2. Single-flight refresh
When a token needs to be refreshed, only one refresh runs at a time. All other in-flight requests wait for the same refresh to complete. The conceptual pattern (production implementations should account for error handling on refresh failures, distributed coordination across application instances, and language-specific concurrency primitives — asyncio locks in Python, mutex guards in Go, and so on):
global tokenState // access_token, refresh_token, expires_at
global refreshPromise // null or a Promise<void>
async function ensureFreshToken():
if tokenState is null or isStale(tokenState, skewMs):
if refreshPromise is null:
refreshPromise = (async () => {
response = await refreshAtProvider(tokenState.refresh_token)
tokenState.access_token = response.access_token
tokenState.expires_at = now() + response.expires_in * 1000
// CRITICAL: handle rotating refresh tokens
tokenState.refresh_token = response.refresh_token
or tokenState.refresh_token
})().finally(() => refreshPromise = null)
await refreshPromise
For a single-instance application, an in-memory promise/lock is sufficient. For distributed systems, you need a Redis-based lock (Redlock pattern) or atomic database operations to prevent multiple application instances from refreshing simultaneously.
3. Retry-once-on-401
Even with proactive refresh and skew handling, the upstream API is the authority on whether a token is valid. If a request returns 401, force a refresh and retry the request once. If it fails again, surface the error — don't retry indefinitely.
async function authedRequest(req):
await ensureFreshToken()
req.headers["Authorization"] = "Bearer " + tokenState.access_token
response = await send(req)
if response.status == 401:
await ensureFreshToken() // forces a new refresh
req.headers["Authorization"] = "Bearer " + tokenState.access_token
response = await send(req)
return response
What about refresh token rotation?
Refresh tokens come in two patterns. Misunderstanding which pattern your authorization server uses is one of the most common production OAuth bugs.
Static refresh tokens
The authorization server issues a refresh token that remains valid until it expires or is explicitly revoked. Every refresh call reuses the same refresh token; the response typically returns a new access token but the same refresh token. Your code stores the refresh token once and reuses it.
Rotating refresh tokens
Every successful refresh returns both a new access token and a new refresh token, and the server immediately invalidates the old refresh token. Your code must update the stored refresh token on every refresh response. If you accidentally keep using the old refresh token, the next refresh attempt fails with invalid_grant.
Rotating refresh tokens add a security property: reuse detection. If the server sees an attempt to use a refresh token that should have been invalidated, it interprets this as a potential token theft and revokes the entire token family — forcing the user to re-authenticate. This is the security argument for rotation, but it amplifies the cost of bugs in your refresh handling.
How to handle both
Always update your stored refresh token from the refresh response. If the response includes a new refresh token, use it. If it doesn't, keep the existing one. This pattern works for both static and rotating integrations without your code needing to know which is which:
response = refresh_at_authorization_server(stored_refresh_token)
stored_access_token = response["access_token"]
stored_expires_at = time.time() + response["expires_in"]
stored_refresh_token = response.get("refresh_token", stored_refresh_token)
Static vs. rotating refresh tokens at a glance
| Property | Static refresh tokens | Rotating refresh tokens |
|---|---|---|
| Refresh response | New access token; refresh token unchanged | New access token AND new refresh token |
| Old refresh token after rotation | Still valid until expiry | Immediately invalidated |
| Storage requirement | Store once, reuse | Update stored value on every refresh |
| Failure mode if mishandled | Token continues to work until natural expiry | invalid_grant on next refresh attempt |
| Reuse detection | Not enforced | Server treats reuse as theft signal |
| Security argument | Simpler implementation | Stronger revocation, theft detection |
| For deeper coverage on managing OAuth at scale across multiple integrations, see our guide on how to handle OAuth across many integrations. For the broader OAuth flow taxonomy, see understanding OAuth2 authorization flows. |
How does Unified handle OAuth token expiry across 70+ variants?
Unified.to treats OAuth as shared infrastructure rather than per-integration code. The token-handling layer covers all the patterns above — proactive refresh, single-flight protection, refresh token rotation handling, and connection-health monitoring — across 70+ OAuth 2.0 variants spanning 440+ integrations.
Proactive refresh, not 401-driven
Unified refreshes access tokens proactively before expiry — typically 5–10 minutes early — using background workers that track expiration centrally. From your application's perspective, expired access tokens are not surfaced; the token attached to your API call is current. 401s are treated as a safety net rather than the primary refresh trigger.
Single-flight refresh per connection
Concurrent API calls that hit the same stale token don't trigger duplicate refresh attempts. Refresh runs once per connection; other in-flight requests wait for the new token. This protects against the race conditions that cause refresh-token chain invalidation on rotating-token integrations.
Refresh token rotation handled automatically
For integrations that rotate refresh tokens (Google, certain Salesforce configurations, Auth0 with rotation enabled, and others), Unified stores the new refresh token from each response automatically. Your application code doesn't need to know which integrations rotate and which don't.
Connection health on refresh failure
When an upstream integration stops honoring a refresh token — invalid_grant from Google after extended inactivity, a user revoking access manually, an integration invalidating tokens after a password change — Unified marks the connection as unhealthy or needs_reconnect, stops trying to refresh, and surfaces the failure through three signals:
- The connection object's status field flips to
unhealthy - API calls against the connection return a consistent 401 with a normalized error code
- A webhook notification fires to your registered notifications endpoint
Your application code reacts to one of these signals and prompts the user to re-authorize. You don't have to parse integration-specific error payloads or track invalid_grant semantics per integration.
Encrypted token storage
Tokens are encrypted at rest, scoped per connection, and can optionally live in your own AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, or HashiCorp Vault rather than Unified's storage. The token never appears in your application code — the API uses a connection_id and Unified handles attaching the right bearer token to each upstream call.
For full API documentation on Unified's authorization layer, see docs.unified.to.
Frequently asked questions
How do I know if my OAuth access token is expired?
Three ways: compute expires_at = now + expires_in when the token is issued and check it locally; decode the JWT exp claim if the token is a JWT; or rely on the upstream API returning a 401 response. The server's response is the only authoritative signal — local checks are for scheduling proactive refresh, not for trusting the token.
What's the difference between a JWT and an opaque access token?
A JWT is a self-contained signed token whose claims (including exp) can be validated locally with a public key. An opaque token is a reference string with no decodable structure — the authorization server is the only entity that knows whether it's valid. Your application should treat both as bearer tokens with an expiry; the validation mechanism differs.
How long do OAuth access tokens typically live?
Most integrations issue access tokens with a 1-hour lifetime, but this varies. Some short-lived tokens expire in 15 minutes; some are configurable per tenant. Always read expires_in from the token response rather than assuming a fixed value.
Should I refresh tokens proactively or wait for 401 errors?
Proactively. Refresh tokens 5–10 minutes before the access token expires. Waiting for 401s makes every expired-token request fail once before recovering, which adds latency and complicates concurrent request handling. 401s should be a safety net, not the primary trigger.
What happens if my refresh token expires?
Refresh token expiry is integration-specific. QuickBooks (Intuit) refresh tokens are valid for 100 days on a rolling inactivity window — every successful refresh extends the window, and the refresh token value itself rotates roughly every 24 hours (the previous value is invalidated when a new one is issued). Google's refresh tokens can stop working for several reasons: the OAuth app is in Testing mode (7-day token lifetime), the user revoked access or changed security settings, the account hit Google's per-client refresh token limits, or the token has gone unused for an extended period (historically around six months). When a refresh token is no longer valid, the authorization server returns invalid_grant on the refresh attempt, and the user must re-authenticate through the full OAuth flow.
How do I handle rotating refresh tokens?
Always update your stored refresh token from the refresh response. If the response includes a new refresh_token, store it and discard the old one. If it doesn't, keep the existing one. The pattern stored_refresh_token = response.get("refresh_token", stored_refresh_token) handles both static and rotating integrations without branching.
What's a single-flight refresh and why do I need one?
Single-flight refresh is a pattern where only one refresh runs per connection at a time. Concurrent API calls that hit a stale token all wait for the same refresh to complete instead of each triggering its own. Without single-flight protection, multiple simultaneous refresh attempts on a rotating-refresh-token integration cause the entire token chain to be revoked.
How does Unified handle OAuth across multiple integrations?
Unified centralizes OAuth into one layer: a single callback endpoint, normalized scopes, encrypted token storage, proactive refresh with single-flight protection, automatic rotation handling, and consistent error semantics across 70+ OAuth 2.0 variants. Your application uses a connection_id and never touches tokens directly.
Final thoughts
The mechanical question — is this token expired? — has a simple answer: check expires_at, decode the JWT exp, or trust the 401. The harder question is what to do about it under concurrent load, across integrations with different refresh semantics, when refresh failures happen for reasons specific to each integration.
For a B2B SaaS product integrating with multiple OAuth integrations, owning that complexity in your application code means writing and maintaining single-flight locks, refresh token rotation handling, clock skew buffers, error normalization across integration-specific failure modes, and connection health monitoring — per integration. A unified authorization layer collapses that into infrastructure rather than feature code.
Unified.to provides OAuth handling across 440+ integrations and 27 categories — one callback endpoint, normalized scopes, encrypted token storage, proactive refresh, and consistent error semantics.
Start a free trial or book a demo to see it work.