If you’re using OpenID Connect (OIDC) with a React frontend and have struggled with silent token renewal, expired tokens triggering infinite API calls, or Content Security Policy (CSP) blocking iframes, this article is for you.
After wrestling with all these issues, I finally managed to build a stable and secure solution and here’s a breakdown of the journey and how you can apply it in your own app.
The Problems I Faced
1. Silent Token Renewal Didn’t Work
OIDC offers a way to silently renew access tokens via an iframe, but this method fails with applications that have a strict CSP, particularly with frame-ancestors ‘none’.
As a consequence, tokens expire unnoticed and users are logged out without any notification.
2. Infinite API Loops After Token Expiry
Expired tokens weren’t detected early enough. When the frontend made API requests with an expired token, the server responded with 401s and our interceptor logic tried to retry the same request again and again. This led to an infinite loop of failing requests, degraded performance, and confused users.
3. Lost Redirect Path After Login
After being redirected to the login page due to an expired token, users were not returned to the original page they were on.
The Final Solution
To address these problems, I made the following structural changes:
- Issue: Implemented Silent renew not working
Fix: Added dedicatedsilentrenew.htmlpage and fix the CSP issue - Issue: Token expiration API loop
Fix: Moved token validation outside of interceptors - Issue: CSP iframe blocked
Fix: Updated server CSP policy to allow'self'for iframes - Issue: Lost redirect path
Fix: Usedstateparameter orredirectToquery in login flow
Key Fixes Explained
1. silentrenew.html:
Silent token renewals happen in a hidden iframe. For this, you need a static HTML file that executes the silent callback:
<!-- silentrenew.html -->
<script src="https://unpkg.com/oidc-client-ts/dist/umd/oidc-client-ts.min.js"></script>
<script>
new Oidc.UserManager().signinSilentCallback();
</script>Make sure to add this page’s URL to your Identity Provider’s Redirect URIs and update your CSP:
Redirect URIs:
https://your-app-url/login-redirect
https://your-app-url/silentrenew.htmlPost-Logout Redirect URI:
https://your-app-url/loginEnable Offline Access:
AllowOfflineAccess: trueConfigure the Content Security Policy (CSP) in the Identity server project:
Content-Security-Policy: frame-ancestors 'self'
2. Validate Tokens Before Making API Calls
A common mistake is to do token renewal inside your axios request or response interceptors.
While it seems convenient, this causes major issues:
- Token renewal might be triggered in parallel by multiple requests.
- Expired tokens may still be sent before renewal completes.
- 401 responses can trigger infinite retry loops.
Solution:
Move token validation logic outside the interceptor, into a centralized place that runs before any API call is made.
function getValidAccessToken() {
const user = userManager.configuration.getUser() //Calls to get the new token
if (!user || user.expired) {
return null
}
return user.access_token
}
const token = await getValidAccessToken()
if (!token) {
redirectToLoginWithState()
} else {
axios.defaults.headers.Authorization = `Bearer ${token}`
}
Why I Removed Token Logic From Axios Interceptors
Interceptors are great but not for token renewal.
Before:
axios.interceptors.response.use(async (response) => {
if (response.status === 401) {
await renewToken()
return axios(response.config) // ❌ Risk of loops
}
})Problems:
- If many API calls are made at once with an expired token, all trigger
renewToken(). - Initial requests are made again before the new token becomes available.
3. Managing Expiry with Events
Lifecycle events such as addUserLoaded and addSilentRenewError are provided by the OIDC client, making them useful for tasks such as token maintenance.
import { User, UserManager, UserManagerSettings, WebStorageStateStore } from 'oidc-client-ts'
const configuration = new UserManager(config)
configuration.events.addUserLoaded(user => {
storeTokenGlobally(user.access_token, user.expires_at)
})
configuration.events.addSilentRenewError(() => {
// Fallback: maybe trigger login redirect after some time
})
4. Handling Login Redirects Cleanly
When redirecting to login, I now preserve the user’s current route using query parameters or state:
const targetPath = window.location.pathname + window.location.search
const loginUrl = `/login?redirectTo=${encodeURIComponent(targetPath)}`
window.location.replace(loginUrl)After successful login, the app reads the redirectTo value and sends the user back where they left off.
Bonus: Environment Setup
Be sure to include the offline_access scope if you want to use refresh tokens or extended silent renewal capabilities:
REACT_APP_IDENTITY_SERVER_SCOPE='openid offline_access'Make sure to have the following default settings as well.
const config: UserManagerSettings = {
authority: appConfig.identityServer.authority,
client_id: appConfig.identityServer.client_id,
redirect_uri: `${window.location.origin}/login-redirect`,
client_secret: appConfig.identityServer.clientSecret,
scope: appConfig.identityServer.scope,
response_type: 'code',
post_logout_redirect_uri: `${window.location.origin}/login`,
automaticSilentRenew: true,
silent_redirect_uri: `${window.location.origin}/silentrenew.html`,
accessTokenExpiringNotificationTimeInSeconds: 120,
includeIdTokenInSilentRenew: true,
revokeTokensOnSignout: false,
}
Results
With these changes:
✅ Silent token renew works consistently
✅ Infinite API loops are eliminated
✅ Redirects are handled gracefully
✅ CSP is respected and secure



