Unified.to
All articles

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 exp claim) 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_at timestamp computed when the token was issued, the exp claim 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

PropertyStatic refresh tokensRotating refresh tokens
Refresh responseNew access token; refresh token unchangedNew access token AND new refresh token
Old refresh token after rotationStill valid until expiryImmediately invalidated
Storage requirementStore once, reuseUpdate stored value on every refresh
Failure mode if mishandledToken continues to work until natural expiryinvalid_grant on next refresh attempt
Reuse detectionNot enforcedServer treats reuse as theft signal
Security argumentSimpler implementationStronger 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.

All articles