help request: OIDC Login Loop & Refresh Token Failures with openid-connect Plugin for SPA + API Gateway Pattern
Description
Hello APISIX Team,
I am facing a persistent issue trying to secure a Single-Page Application (SPA) and its backend API using the openid-connect plugin with Keycloak as the IdP. My goal is to have all security managed by the APISIX platform, without any OIDC logic in the frontend application itself.
Architecture:
- APISIX as the API Gateway.
- Keycloak as the OIDC Identity Provider.
- Frontend: A static SPA served by an Nginx container.
- Backend: A REST/OData API served by another container.
- Both frontend and backend need to be protected, as the frontend needs user identity to render correctly.
The Problem:
I cannot find a working configuration for the openid-connect plugin that avoids either a) token refresh failures, or b) login loops.
What I Have Tried:
Separate Routes, Separate OIDC Plugins, Unified Keycloak Client:
- Route 1 (/*, for frontend) with openid-connect plugin.
- Route 2 (/api/*, for backend) with an identical openid-connect plugin configuration.
- Result: Login works. Claim propagation works. However, after the access token expires, the token refresh fails. Keycloak logs show the error invalid_grant: "Token client and authorized client don't match". This happens because the refresh is initiated by the plugin instance on the /api/ route, but the token was originally obtained by the plugin instance on the /* route. The context (likely due to different redirect_uri configurations in the plugins) does not match.
Shared Callback Route:
- To solve the context mismatch, I configured both openid-connect plugins to use the exact same redirect_uri (e.g., https://.../auth/callback).
- A dedicated route for /auth/callback handles the redirect back to the application.
- Result: This leads to a login loop. The OIDC flow is never completed because the /auth/callback route does not have the openid-connect plugin to handle the code-for-token exchange.
Unified redirect_uri on Main Route:
- I configured both plugin instances to use the application root (https://.../) as the redirect_uri.
- Result: This causes the openid-connect plugin to fail with a 500 Internal Server Error on the initial visit to /. The logs show unhandled request to the redirect_uri: /. The plugin cannot handle when the application entry point is the same as the configured redirect_uri.
Conclusion:
It seems impossible to have two separate routes protected by the openid-connect plugin in bearer_only: false mode, as the session/refresh context cannot be reliably shared. At the same time, using a single route to protect both also presents challenges.
Is there a recommended, official architecture for this common use case (platform-managed security for an SPA)? Am I missing a configuration option, or is this a limitation of the current plugin?
Thank you for your help.
Environment
- APISIX version (run
apisix version): 3.11.0 - Operating system (run
uname -a): Ubuntu 24.04.2 (kernel 6.8.0-64-generic) - OpenResty / Nginx version (run
openresty -Vornginx -V): nginx v1 (docker image: nginx:1-alpine)
We have started checking and will be feedback here when there is a conclusion.
Hi, @RegMTS thanks for the detailed report and clear description of your use case.
Currently, Apache APISIX’s openid-connect plugin does not share session / refresh token context across multiple routes. This is why you are observing either refresh token failures or login loops when trying to protect both SPA and API routes with separate plugin instances.
One possible direction to address this limitation would be to allow the plugin to use an external session store (e.g., Redis), so that different routes (or even different APISIX nodes) could share the same OIDC session context. In theory, this could solve the mismatch you are experiencing during token refresh.
At the moment, this feature is not implemented. We’d be very interested to hear your feedback:
- Would external session storage fit your architecture?
- Do you see other approaches that could help in your scenario?
Contributions and design discussions around this feature are very welcome. Thanks again for raising this important use case!
Hi @moonming,
thank you so much for the quick and transparent response. It's very helpful to have confirmation that this is a current limitation of the plugin's architecture and not a misconfiguration on my end.
To answer your questions directly:
-
On external session storage: While using an external store like Redis would certainly solve the immediate problem, I see it more as a solution for scalability and high availability. From my perspective, an ideal solution would allow APISIX to manage and share this context natively between routes, without the architectural dependency on an external component for a baseline setup. This would keep the deployment simpler for many common use cases.
-
On other approaches: Actually,
bearer_only: falseis not a strict requirement for the backend route. An alternative pattern where only the frontend route (/*) handles the full OIDC flow is very interesting.
The absolute key to this pattern, and the core of our requirement, is the seamless refresh token scenario. When the user's session on the frontend expires, the openid-connect plugin should transparently refresh the token. This newly refreshed session/token must then be made available to the backend route. This ensures that the user can continue to consume the backend APIs without any interruption or need to re-authenticate.
Is there a recommended way within APISIX to achieve this "token forwarding" or context propagation? For example, could the openid-connect plugin on the frontend route automatically propagate the refreshed token to subsequent calls targeting the backend route, which could then be protected by a simpler mechanism like jwt-auth? Crucially, this JWT must contain the same identity claims found in the OIDC session cookie, as our backend services rely on this information to operate correctly. The critical link is ensuring the backend route benefits from the refresh cycle managed by the frontend route.
I am very keen to contribute to this discussion as I believe this is a cornerstone use case for leveraging APISIX as a complete security gateway for web applications.
Thanks again for your engagement on this.
@RegMTS
@moonming recommends using Redis for session sharing, but the reason is not complete:
If you use the same session.secret on multiple routes, session sharing will be possible, but you must explicitly configure it; otherwise, APISIX will generate and populate it.
By default, you are using cookie-based sessions, where session data is encrypted with the secret and sent to the client as a cookie. Since the client does not know the secret, it cannot decrypt or modify it, making it secure. In this case, the data is not stored or persisted by any central component. Although APISIX can delete cookies from the browser, you cannot revoke a session because malicious actors can recover the session by recording and manually reconfiguring the cookie. Despite this, it is still secure, just a minor drawback of this stateless scheme. It is similar to the situation we encounter when using JWT. Additionally, since the cookie is sent with every HTTP request header, it may become quite large, potentially wasting your server bandwidth and triggering request header limits on your reverse proxy chain. For example, as far as I know, Nginx has such a default limit, which may result in a 400 "too large request header or cookie" error.
A viable alternative is to use APISIX shdict to store session data and only send the session ID cookie to the client. This keeps the data on the server, and the client can never access it, so even if your secret is compromised, it remains secure. However, shdict cannot be efficiently propagated across multiple APISIX instances, which poses an obstacle to deploying an APISIX cluster.
Therefore, Redis is listed as a compromise solution. It ensures that session data is retained solely on the server while reducing request headers, and it also enables seamless data sharing across multiple APISIX instances.
This is the issue you encountered when setting bearer_only to false.
Another completely different approach is to set bearer_only to true.
In this case, APISIX will no longer use sessions or redirect clients to the IDP. Instead, it will ONLY verify the access token in the client API call. If the verification passes, the request will be forwarded to the upstream server. Otherwise, a 401 status will be returned to the client.
Your "client" fully controls the authentication process. Let's take a web app as an example. Your web app is deployed on a CDN, and traffic to the web page does not pass through APISIX.
When a user first opens the page, your page explicitly knows that no user credentials (access token) are stored in the browser, so you need to display a prompt informing the user that they need to log in. After the user clicks the button to redirect to the IDP and completes the entire authentication process (e.g., via the OIDC implicit flow), your web app will obtain the access token. Subsequently, the web app needs to request the API to retrieve the user's data. At this point, the request will pass through APISIX, which only needs to attach the access token. APISIX can then verify the credential, extract the user's identity, and pass it on to the upstream service.
When the credentials in the browser expire, if the user accesses the application again, the browser will attach an invalid access token to access APISIX, and you will receive a 401 response. At this point, simply prompt the user to log in again, and they will be able to access the application once more. Additionally, since web apps can also obtain a refresh token, they can refresh the access token while the refresh token is still valid, even if the access token has expired. Users are unaware of token expiration and refresh, so they do not experience any interruptions.
In this mode, APISIX will no longer be responsible for any tasks related to login and session/token issuance. Your application will handle these tasks independently, starting from obtaining and storing the access token. Since it is only verification, there is no need for server-side persistence, and Redis is not required. This helps create a seamless user experience. When a user clicks “Log In” on your page, they will be redirected to the IDP to complete the login process, after which the application can be used.
When bearer_only is set to false, it is primarily used to add authentication to existing applications that you cannot modify. When users access the application, if there is no session data in the cookie, APISIX will redirect them directly to the IDP for login. Users will see a brief white screen before being redirected directly to the login page, rather than seeing your brand information followed by a brief, reasonable interaction before being properly redirected to the IDP.
Hi @bzp2010 and @moonming,
thank you so much for the detailed and in-depth explanation. It perfectly clarifies the trade-offs of each approach. I really appreciate the time you dedicated to this analysis.
I now have a much better understanding of the reasoning behind the Redis suggestion, especially for scalability and to overcome the limitations of cookie-based sessions (size, no revocation) and shdict (cluster propagation).
Our primary architectural goal remains to centralize the security logic in the gateway, completely abstracting it from the Single-Page Application, as if it were an application we cannot modify. For this reason, the bearer_only: true mode (which shifts the OIDC logic to the frontend) is not our first choice, although I understand its benefits in other contexts.
In light of this, and considering that our deployment does not require an APISIX cluster for the time being, the shdict-based solution seems like the ideal compromise for our use case. It keeps the session server-side, solving the issues related to cookie size, and it is a native feature.
I've looked through the documentation, but I couldn't find a specific example that illustrates how to configure two routes to share a session via shdict. To proceed with testing, I would need your help.
Would you be able to provide a configuration example for the two routes (/* and /api/*) that shows how to correctly enable and set up session management via shdict in the openid-connect plugin, in order to solve the context-sharing problem?
Thanks again for your time and for your support.
@RegMTS
I've looked through the documentation, but I couldn't find a specific example that illustrates how to configure two routes to share a session via shdict. To proceed with testing, I would need your help.
This feature is supported by the underlying library lua-resty-session. While it's already out of the box for the library itself. Unfortunately, APISIX currently doesn't directly expose any capability to dynamically configure it.
Although I believe it's on our to-do list (as some other users have also mentioned this need), we currently lack sufficient manpower to complete this work, whether from API7.ai engineers or other volunteers in the community.
This doesn't mean we can't configure it at all—there are indeed some workarounds available.
- Directly manipulate the underlying library by setting variables. ref: https://github.com/bungle/lua-resty-session/blob/v3.10/lib/resty/session.lua#L353 ref: https://github.com/bungle/lua-resty-session/blob/v3.10/lib/resty/session/storage/shm.lua#L23
nginx_config:
http_server_configuration_snippet: |
set $session_storage 'shm';
set $session_shm_store 'sessions'; # shdict name
- Set the name of the shdict used for the session.
nginx_config:
http:
custom_lua_shared_dict:
sessions: 100m # 100MBytes
Combine the above configurations.
- Set the same
session.secretvalue across multiple routes.
I think this should work, but I haven't tested it myself yet. So you can give it a try. If any issues arise, you can report them here.
(Alternatively, if you prefer, you can also contribute this feature to support setting session-related configuration items directly within the plugin configuration.
I have been configuring the exact same scenario a few months ago. Both /* and /api/* routes share the same openid-connect configuration with one small difference. On the API route I set
unauth_action = deny
hoping that this might prevent redirects from an API call to the login page if the token has expired in the meantime and letting APISIX request a new auth token from the IDP. However, this doesn't work as I never see a refresh token call in the IDPs backend (also not from the frontend route).
@RegMTS would you mind sharing your openid route config? Mine looks like this:
routes:
-
name: PORTAL
methods: ["GET", "POST"]
uris: [ "/portal", "/portal/*", "/favicon.ico" ]
plugins:
openid-connect:
client_id: *redacted*
client_secret: *redacted*
use_nonce: true
use_pkce: true
use_jwks: true
timeout: 10
session.cookie.lifetime: 1800
set_access_token_header: true
access_token_in_authorization_header: true
discovery: https://idp.example.com:444/idp/.well-known/openid-configuration
redirect_uri: https://idp.example.com:9443/aiportal/.apisix/redirect
logout_path: /portal/.apisix/logout
post_logout_redirect_uri: https://idp.example.com:444/idp/logout
bearer_only: false
realm: APP
session:
secret: *redacted*
unauth_action: auth
redirect:
http_to_https: true
opentelemetry:
sampler:
name: always_on
upstream:
scheme: https
nodes:
"app-backend.exmaple.com:443": 1
type: chash
hash_on: cookie
key: PHPSESSID
-
name: API
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"]
uris: [ "/webservice/OData4Service.svc/*", "/webservice/rest/*" ]
plugins:
openid-connect:
client_id: *redacted*
client_secret: *redacted*
use_jwks: true
timeout: 10
session.cookie.lifetime: 1800
set_access_token_header: true
access_token_in_authorization_header: true
discovery: https://idp.example.com:444/idp/.well-known/openid-configuration
bearer_only: false
realm: APP
session:
secret: *redacted*
unauth_action: deny
redirect:
http_to_https: true
opentelemetry:
sampler:
name: always_on
upstream:
scheme: https
nodes:
"api-backend1.example.com:8443": 1
"api-backend2.example.com:8443": 2
type: roundrobin
timeout:
connect: 60
send: 180
read: 180
The session is shared (with the same secret) and true, it sends a pretty large cookie back to the client. I would also like to avoid that, if possible. Since APISIX needs to be clustered, the shdict approach does not work for me, so I gotta wait for Redis.
So, just like your original post, the first approach of yours works best, so far. The cookie is a bit long, but now we know why, and the automatic token refresh doesn't seem to occur. Hopefully its just a difference between your and my config.
Due to lack of the reporter's response this issue has been labeled with "no response". It will be close in 3 days if no further activity occurs. If this issue is still relevant, please simply write any comment. Even if closed, you can still revive the issue at any time or discuss it on the [email protected] list. Thank you for your contributions.