Oracle Fusion REST API: OAuth Token Expiration and Refresh (Why a Working Integration Starts Throwing 401s)
A pattern that shows up repeatedly on Cloud Customer Connect and the Oracle Forums: an integration authenticates fine, runs for a while, then starts throwing 401 Unauthorized with no code change on either side (“401 Unauthorized error when using Fusion Data extraction REST API”; “401 Unauthorized when attempting to access Fusion Applications HCM REST API with OAuth”). Our authentication guide covers the three ways to authenticate and the 401-vs-403 split, but treats token expiry as one row in a table. This post is the deeper dive: what actually expires, how to refresh it, and the two errors — invalid_grant and “token has already been consumed” — that trip up most refresh implementations.
Access tokens are short-lived on purpose
An OAuth 2.0 access token issued by your Fusion instance’s identity domain (IDCS, or OCI IAM on newer tenancies) is not meant to outlive a single work session. Oracle’s own recommended default is 3,600 seconds (1 hour) for access token expiration, and it’s a setting the identity-domain administrator controls per confidential application — treat it as configurable, not hardcoded, and check your app’s actual token settings before assuming a number. The refresh token that comes with it lives longer but is still capped: Oracle recommends 604,800 seconds (7 days) for refresh token expiration, and that ceiling cannot be raised past 7 days regardless of app configuration — a detail that matters if you’re writing something that runs weekly rather than continuously, since a refresh token that sits unused for a week-plus dies on its own schedule, independent of whether it was ever “consumed.”
This is fine for interactive use and for short scheduled jobs. It becomes a problem the moment you write a long-running or continuously-polling integration (an Atom feed consumer, a sync daemon, a webhook receiver reacting to business events) that expects the same credential to keep working for hours or days. It won’t — and the failure mode is a clean 401, indistinguishable at a glance from a wrong password or a clock-skew problem, which is exactly why the two Fusion threads above start with someone assuming they broke something.
Not getting a refresh_token at all? Check your scope before your grant type
Before troubleshooting the refresh flow itself, make sure your original token request is actually configured to receive a refresh token. Two mistakes cause this, and both look identical from the outside — an access_token with no refresh_token field anywhere in the response:
Missing offline_access in the scope. For the OAuth 2.0 Authorization Code flow — the one used by user-facing integrations and by OIC connections to Fusion — Oracle’s identity domain only issues a refresh token if the scope explicitly includes offline_access alongside the Fusion resource scope. Leave it out and you’ll get a working access token with no way to refresh it, which is easy to misread as “refresh isn’t supported for this flow” when it’s actually just a missing scope value.
A scope borrowed from the wrong Oracle Cloud service. The scope has to be the exact value configured on your resource application inside your identity domain (IDCS or OCI IAM) — don’t construct or guess it, and don’t reuse a scope example from a different Oracle Cloud service’s docs (OCI’s other REST APIs use their own distinct scope namespaces). A scope that doesn’t belong to your Fusion instance will either fail the token request outright or return a token that doesn’t actually grant access to Fusion HCM/FSCM resources. A real, working example from Oracle’s own integration guidance, combining a Fusion resource scope with offline_access:
scope=urn:opc:resource:fa:instanceid=<your-instance-id> urn:opc:resource:consumer::all offline_access
Treat that as a pattern, not a literal string to copy — the exact scope value is whatever your identity domain admin configured when the resource was added to your confidential application, and it’s visible on the application’s Resources/Scopes tab. If your response is missing refresh_token, this is the first thing to check, before assuming anything is wrong with the refresh call itself.
The refresh_token grant
If your token request used a grant type that returns a refresh token — the Authorization Code flow does when offline_access is in scope per the section above, and the user-assertion flow typically does as well; a bare client-credentials grant typically does not, since there’s no user session to refresh — the token response includes a refresh_token alongside the access_token. Use it to get a new access token without repeating the full authentication flow:
curl -u '<client_id>:<client_secret>' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=refresh_token' \
--data-urlencode 'refresh_token=<the refresh token from the last response>' \
'https://idcs-abc123.identity.oraclecloud.com/oauth2/v1/token'
A successful response looks like the original token response: a new access_token, a new expires_in, and — this is the detail that causes most bugs — usually a new refresh_token too.
Error 1: invalid_grant
Calling the refresh endpoint with a bad refresh token returns an invalid_grant error — per the OAuth 2.0 spec this is normally 400 Bad Request, though some Fusion identity-domain flows (notably JWT-bearer grants presented directly as an expired token) have been reported returning 401 Unauthorized with the same error: invalid_grant body instead. Don’t rely on the HTTP status alone to distinguish this from a plain expired-access-token 401 — check the response body’s error/error_description fields. Three separate causes collapse into the same invalid_grant error:
- The refresh token itself expired (refresh tokens have their own, longer-but-still-finite lifetime, set independently of the access token’s).
- The refresh token was explicitly revoked (user password change, admin revocation, app deregistration).
- The refresh token was already used — see the next section, because this is the one that actually causes most support tickets.
invalid_grant on a refresh call means “stop trying to refresh, re-run the original authentication flow” — there’s no recovery path that reuses that same refresh token.
Error 2: “the token has already been consumed”
This is the gotcha that isn’t obvious from the docs: Fusion’s identity domain issues single-use, rotating refresh tokens. Every successful refresh_token call invalidates the refresh token you just spent and returns a brand-new one in its place. If your integration code does any of the following, it will eventually hit “already consumed”:
- Retries the same refresh call on a timeout, not knowing whether the first attempt actually succeeded server-side. The first call rotated the token; the retry sends the now-dead one.
- Runs refresh logic from two processes or threads concurrently against the same stored refresh token — whichever call loses the race gets a token that’s already been spent by the winner.
- Caches or hardcodes an old refresh token (a
.envvalue, a value pasted into a script) instead of always persisting the newest one the server just returned.
The fix in all three cases is the same discipline: treat “refresh” as a single-writer operation (a lock or a queue, not a race), and overwrite your stored refresh token with the new one from every response — including the one that comes back with the access token you’ll actually use.
A practical pattern for long-running integrations
Waiting for a 401 before refreshing works, but it means every token expiry costs you one failed request in production. The more robust pattern:
- Store the access token’s
expires_in(or compute an absolute expiry timestamp) alongside the token itself. - Before each call, check whether the token expires within the next minute or two. If so, refresh proactively — don’t wait for the 401.
- If a call still comes back
401despite a fresh-looking token (clock skew, an admin revoked the app mid-session), refresh once and retry the request exactly once. Don’t loop. - On
invalid_grantfrom the refresh call itself, stop refreshing and fall back to the full authentication flow — a refresh token that’s dead needs a new login, not another refresh attempt.
Diagnostic table
| Symptom | Cause | Fix |
|---|---|---|
401 after the integration ran fine for a while | Access token expired — this is expected behavior, not a bug | Refresh proactively before expiry, or catch the 401 and refresh-then-retry once |
400 invalid_grant on the refresh call | Refresh token expired, revoked, or already used | Re-run the full authentication flow; there’s no way to “fix” a dead refresh token |
| ”Token has already been consumed” specifically | Two callers (a retry, a race between threads/processes) both tried to use the same refresh token | Make refresh a single-writer operation; always persist the newest refresh token returned |
401 even immediately after a successful token request | Clock skew between your system and the identity domain, or the token simply wasn’t propagated yet | Check system clocks; for JWT trusted-issuer tokens, keep iat/exp generous enough to absorb small skew |
Where OPAL fits
While you’re building and testing this refresh logic, re-running the same request by hand with a freshly minted token — to confirm the new token actually works before you wire the retry logic into production — gets tedious with raw curl. OPAL stores your credentials locally encrypted (AES-256-GCM, no cloud sync) and lets you drop in a new token and re-send the identical request from its history, so you can validate each piece of the refresh flow (does the new access token work? does the new refresh token work next time?) without hand-editing headers between attempts.
This post extends our Oracle Fusion REST API authentication guide — Basic Auth, JWT bearer, and OAuth 2.0 setup, plus 401-vs-403 troubleshooting. It’s also part of the complete Oracle Fusion API guide.
Explore Oracle Fusion APIs offline
OPAL bundles 59,000+ Oracle Fusion REST endpoints, fully searchable offline, with a visual Q Builder and Finder Builder that only offer fields the endpoint actually accepts — so your filter can't 400.
Free, no account required. Pro adds live requests and multi-step Flows.