Skip to content
Blog QESLE

Integrating WorkOS Connect with a public web client: lessons learned

Authentication

Version for AI agents (Markdown)

On October 30, during the production acceptance testing of QESLE, I wanted to test an invitation sent from the WorkOS dashboard. Sign-up was closed and the waitlist turned on. The idea was simple: let people ask for access, without letting just anyone create an account.

So I sent an invitation to a test account. When I opened the link from the email, I landed on a page offering to join the waitlist.

I clicked the link for people who are already invited ("Déjà invité ? Connectez-vous ici" on the French page), then entered the account's email address. Except WorkOS asked me for a password. A password I had never had a chance to create. The only option that seemed to work was to go through the password reset.

By then, I had been working on the WorkOS integration for a little over two weeks. Sign-in worked, token renewal worked too, and I had already fixed several issues along the way.

But I had missed a fairly basic case: inviting someone while sign-up is closed, letting them set their password, then letting them into the application.

In this article, I go back over the problems I ran into during this integration, from a CORS setting made in the wrong place to this invitation tested a little late. And over what I discovered a week later, when I put a public demo online.

This write-up covers WorkOS Connect with a public web client, using PKCE, without a secret, and an API that verifies tokens itself. With the AuthKit SDK or a server-side session, you will not necessarily run into the same problems. The observations and support answers reported here date from October and early November 2026.

Why I tried WorkOS

QESLE, short for "Qui est sur l’env ?" (French for "Who is on the environment?"), shows who is using an environment and lets you book it. The application runs on a VPS, with a React client, a FastAPI API and PostgreSQL.

For authentication, my starting requirement was simple: not having to manage passwords myself. On the recommendation of Theo Browne, I decided to try WorkOS.

Two other things appealed to me: AuthKit was free up to one million monthly active users, and I could later add enterprise SSO without changing the token issuer.

With Connect, I could keep my own OIDC client and request an audience dedicated to the API. Here is the setup I chose:

  • a public web client, without a secret, using Authorization Code with PKCE;
  • the resource parameter from RFC 8707 to designate the API the token is meant for;
  • tokens kept in memory only, renewed with a refresh token;
  • no WorkOS secret key on my server.

Before touching QESLE, I wrote a small probe page on the test environment. It let me sign in, read the tokens, renew them, reload the page and sign out. It took me half an hour and served me throughout the integration.

I confirmed that PKCE worked without a secret, that the audience did come from resource and that refresh token rotation worked. My API accepted WorkOS tokens without any change.

The probe also revealed two problems: the browser could not exchange the code for a token directly, and the client had no way to close the WorkOS session. With these first findings, I contacted support on Slack and talked with Alida.

1. I had configured CORS on the wrong application

I had created a second AuthKit application for QESLE, next to the Connect application, and declared the site origin in its CORS settings.

The preflight passed. The response to the actual request came back without an Access-Control-Allow-Origin header.

To move forward, I set up a relay on my own domain to forward the exchange to WorkOS. I had even started preparing it for production, with rate limiting.

Alida then explained that Connect used the allowed origins of the environment's default AuthKit application.

I added the origin there. The direct exchange worked and I removed the relay the same day, two days after setting it up.

I was looking at the right setting, but on an application that Connect did not read. This confusion would cost me time a second time.

2. Two seconds of clock skew broke sign-in

I sometimes had to click Sign in twice. Some renewals also failed, with no visible cause.

WorkOS tokens carried an issue date one to two seconds ahead of my server's clock. PyJWT therefore rejected tokens it considered issued in the future.

I added a 60-second tolerance on iat, the issue date, and on nbf, the start of validity when present. I kept a strict check on exp, the expiry.

That last point needs some care with PyJWT: its leeway option also applies to exp. A leeway=60 would therefore also accept a token that expired less than a minute ago.

To get the behavior I wanted, I disabled the built-in iat and nbf checks, then performed those checks separately. Here is the idea, with the audience and issuer configured for the API:

import time
import jwt  # PyJWT

TOLERANCE = 60  # seconds, for iat and nbf only


def verify_token(token: str, public_key) -> dict:
    claims = jwt.decode(
        token,
        public_key,
        algorithms=["RS256"],
        audience=API_AUDIENCE,
        issuer=WORKOS_ISSUER,
        options={
            "require": ["exp", "iat"],
            "verify_iat": False,
            "verify_nbf": False,
        },
    )  # exp is still verified, with no tolerance

    limit = time.time() + TOLERANCE

    for name in ("iat", "nbf"):
        if name not in claims:
            continue

        value = claims[name]

        if type(value) not in (int, float):
            raise jwt.InvalidTokenError(f"{name} must be a number")

        if value > limit:
            raise jwt.ImmatureSignatureError(f"{name} is in the future")

    return claims

The type check notably prevents a malformed value from causing an internal error instead of a token rejection.

3. The session lifetime did not limit time spent in the application

I had set the access token lifetime, the inactivity timeout and the maximum session lifetime. I thought I had limited how long someone could stay signed in.

But these settings cover three different things:

  • The access token is presented to my API. It expires after 30 minutes in my configuration.
  • The browser's AuthKit session lets someone go through sign-in again without retyping their password. It has an inactivity timeout and a maximum lifetime.
  • The Connect refresh authorization is used to obtain new access tokens. According to Alida, it had neither a maximum lifetime nor an inactivity timeout, and no setting allowed setting one.

A tab left open can therefore keep renewing its tokens after the AuthKit session has expired. Refresh token rotation does not change this.

In QESLE, the AuthKit session limits matter again when the person goes through sign-in, for example after a page reload, since the application's tokens are only in memory.

I also found out that the token lifetime and the session limits were set on the default AuthKit application.

For a week, I believed my second application controlled the token lifetime. Both applications showed the same values, which hid my mistake. To check, I set five minutes on the default application only. The next token lasted five minutes.

Finally, these settings had not carried over to production. There I found the original values: five minutes for the token, two days of inactivity and one year of session at most.

I chose 30 minutes for the token, one day of inactivity and seven days of session. The 30 minutes space out renewals and therefore reconnections of QESLE's real-time stream. On the other hand, a token already issued can remain usable for that whole period after the account is deleted.

4. Sign out did not close the WorkOS session

In QESLE, signing out erases the application's tokens. It does not close the browser's AuthKit session and does not revoke the Connect authorization.

I looked for a way to go further. The OIDC discovery of my test environment advertised neither end_session_endpoint nor revocation_endpoint. I also tried the WorkOS session logout call with the token's sid: it returned 200 but closed nothing.

Alida explained why: in a Connect token, this sid identifies an application consent, not a session.

The practice she pointed me to is to sign out locally, then send prompt=login on the next sign-in to ask for the password again. That is the behavior QESLE uses after a click on Sign out.

The password form coming back does not mean the old session or the authorization was revoked. On a shared computer, keep this in mind: the AuthKit session stays open after signing out of QESLE.

A simple page reload behaves differently. It loses the in-memory tokens and brings the person back to the home page, but the next click on Sign in can reuse the AuthKit session without asking for a password.

I accept this trade-off so that no token is kept in browser storage. It does not protect against a malicious script running in the page. My first defense against that risk is a strict CSP, with script-src 'self', which only allows the site's own scripts.

5. Deleting an account did not cut off access immediately

For the pilot, I needed a procedure to remove someone's access.

During my tests, the user page of the WorkOS dashboard offered to delete the account, but not to block it. Its "Sessions" tab did not show the user's Connect sign-ins.

Alida pointed me to an API that can revoke the Connect authorization. It requires a WorkOS secret key. I have not adopted it for now: it would add that key to my tooling and, on its own, revocation would not prevent the person from signing in again and authorizing the application once more.

So I tested account deletion in production:

  • my API kept accepting the access token already issued, until it expired;
  • WorkOS refused the next renewal;
  • QESLE displayed "Votre session a expiré" ("Your session has expired"), without trying to sign in again automatically.

With my configuration, the delay could reach 30 minutes.

Deleting the account in WorkOS did not remove the memberships in QESLE either. The person remained a member of their teams and their history remained visible according to the usual permissions. A complete offboarding procedure must therefore also remove their access to teams in QESLE.

If this person comes back later through an invitation, WorkOS creates an account with a new identifier. QESLE then treats them as a new person, without automatically linking them to their old history.

That is the procedure I adopted for the pilot, with these limits.

6. The invitation got lost between AuthKit and my Connect flow

Back to the test account of October 30.

The link in the email should have opened the invitation acceptance screen, with first name, last name and password fields. In my flow, it first led to the QESLE home page, without starting sign-in. Once sign-in started, the invitee landed on the waitlist. Then the link for invited users asked for a password they had never set.

In the dashboard, their account remained inactive and their invitation pending.

The invitation flow went through the default AuthKit application. My sign-in, on the other hand, went through Connect, without finding the context of this invitation.

I tried three approaches that day.

First, I added a /connexion route to the client. It starts sign-in as soon as it opens. Declared as the Initiate login URI, it removed the first detour through the home page. But it still started Connect, and the invitee landed on the waitlist again.

I then tried to pass the invitation to Connect with invitation_token, then context. These parameters were not taken into account in my tests.

The flow finally worked by letting the default AuthKit application handle the invitation itself, before starting Connect.

Its Initiate login URI now points to its own AuthKit authorization, with provider=authkit and a return to /connexion:

https://api.workos.com/user_management/authorize?client_id=<client_id of the default application>&provider=authkit&response_type=code&redirect_uri=https%3A%2F%2F<your-domain>%2Fconnexion

You must use the client ID of the default application, not the Connect one, and encode the value of redirect_uri.

The flow becomes:

  1. The invitation link goes through the Initiate login URI of the default application.
  2. That address starts its AuthKit authorization.
  3. The person enters their first name, last name and password. An AuthKit session opens.
  4. AuthKit returns to /connexion with a code that QESLE does not exchange: at this point, only the opening of the session matters.
  5. /connexion starts QESLE's Connect flow, which finds that session and does not ask for the password again.

I also had to add /connexion to the Redirect URIs of the default application. Without it, WorkOS displayed an error.

In the same tab, I left User invitation URL and Password reset URL empty. The Initiate login URI handles this flow, with no additional server code.

I did not need to resend the invitation. The still-pending link was enough: the person created their password and arrived in QESLE. Password reset links also go through this Initiate login URI.

One usability issue remains. With sign-up closed, a person who is already registered and signs in without a reusable session, or after an explicit sign-out, also sees the waitlist first. They have to choose the link for invited users.

The flow works, but this detour still deserves a fix. My next lead is to route the sign-in entry point through the default application, which can ask for the sign-in screen directly. I had not validated it yet when writing this article.

7. The announced limits did not explain every 429

Support gave me the following limits for hosted AuthKit, which I had not found in the documentation:

  • 10,000 requests per minute;
  • 500 writes per ten-second window, code exchanges and renewals included.

According to that answer, budgets are counted per AuthKit domain and per point of presence, not per application or per source address.

For QESLE, these numbers left plenty of headroom. Yet I ran into too_many_requests errors for a few minutes on the test environment.

An email verification completed from the link in the message had lost the return to QESLE. While trying to resume the flow, about ten sign-in and sign-up attempts were enough to trigger the errors. I suspect a finer-grained protection on the sign-in flow, but I do not know its rule.

When facing a 429, support recommends honoring Retry-After when it is present. Otherwise, attempts should be spaced out progressively, with some randomness.

The QESLE client already honors this header when reconnecting its real-time stream. For the WorkOS renewal, it retries a transient failure, 429 included, in four attempts at most, spacing out requests with some randomness and without going past the token's expiry. It cannot honor Retry-After there, because the OIDC library does not expose it.

A definitive refusal such as invalid_grant ends the session immediately.

8. A public demo made me set everything up a third time

In early November, I opened a public demo of QESLE at demo.qesle.com. Three shared personas, Alice, Bob and Claire, sign in with passwords given on the home page, and the data is reset every night.

A published password must not open any production account. So the demo has its own WorkOS environment, with its own AuthKit domain and therefore its own issuer: the production API refuses all of its tokens.

A Staging environment might have seemed natural, but WorkOS does not intend it for public traffic. The demo uses a second environment of the Production type.

The OAuth application is created in the Connect section. A first application had been created in AuthKit's Applications list. It did not work: only the client ID of an application under Connect › Applications works with QESLE's Connect flow. I deleted it.

Nothing carries over from one environment to another. At the first check of the demo environment, email and password sign-in, the Connect application, the API resource in Connect › Configuration and the JWT template that adds the display name were all missing. The CORS origin, on the other hand, was correctly set on the default application, Sessions tab. This is the lesson of section 3: a new environment is set up like a fresh installation.

login_hint works with Connect. The demo page offers one "Sign in" button per persona. It copies the password, then opens AuthKit with the persona's address already filled in, thanks to the login_hint parameter of the authorization request. I did not find it in the Connect documentation, but WorkOS honors it.

It must come with prompt=login. Without it, if Alice's AuthKit session is still open in the browser, Bob's button signs Alice back in without showing any form. With prompt=login, the form is shown every time, prefilled with Bob's address.

With oidc-client-ts, both parameters go through the arguments of the redirect:

await manager.signinRedirect({
  login_hint: 'bob@demo.qesle.com',
  prompt: 'login',
});

I did not put them in extraQueryParams: an extraQueryParams given to the redirect would replace the one from the client settings instead of adding to it.

Connect sessions do not show up in the sessions API. Every night, the reset script was also meant to close the personas' sessions, with GET /user_management/users/{id}/sessions then POST /user_management/sessions/revoke. On the demo machine, the list comes back without the sessions opened by the Connect flow, and nothing gets revoked. It is the same gap as the dashboard's "Sessions" tab, seen in section 5.

An open AuthKit session therefore survives the reset, until its maximum lifetime, which I brought down to one day on this environment. For the demo, the protection comes from elsewhere: every night, the password is reset and the data is recreated. I am still looking for a way to revoke these sessions.

The settings I wish I had spotted from the start

I keep this table as a cheat sheet. Dashboard menus may change.

Setting Location How I checked
CORS origins used for the token exchange Default AuthKit application → Sessions Alida's explanation, then tests in test and production
Access token lifetime Default application → Sessions Probe with a lifetime changed on that application only
AuthKit session inactivity and maximum lifetime Default application → Sessions Support
Connect refresh authorization lifetime No setting available; no maximum lifetime or inactivity timeout according to Alida Support
Initiate login URI for invitations and password resets Default application → Redirects Production acceptance testing
/connexion return URI of the invitation flow Default application → Redirects Production acceptance testing
Public PKCE client and Connect return URI Connect → Applications; an application from AuthKit's Applications list does not work Production configuration, then demo
API resource, used as the audience Connect → Configuration → Resource Indicators Probe
Extra claims, such as the display name Authentication → Features → JWT Template; one template for the whole environment, also applied to Connect tokens Probe
Closed sign-up and waitlist Authentication → Features, for the whole environment Production acceptance testing
Fallback language of AuthKit pages Authentication → Features → Localization; otherwise, the browser language Configuration
Look of AuthKit pages Branding, with per-application customization; to be copied between environments Configuration
Address prefilled at sign-in login_hint on the Connect authorization, with prompt=login; not documented for Connect Public demo
Environment for a public demo A separate environment of the Production type; Staging is not intended for public traffic WorkOS documentation

The second AuthKit application of my test environment turned out to be useless. In production, the Applications list only contains the default application; the Connect application is in the Connect section. In the demo environment, an OAuth application created in the Applications list did not work either.

What I take away from the integration

I ended up with an API independent of the identity provider: it verifies RS256-signed tokens, their signature, issuer, audience and validity dates, without embedding any WorkOS SDK.

WorkOS saved me from writing and maintaining password management. The sign-in pages are displayed in French for a French browser, and support gave me precise answers about behaviors I had not found in the documentation.

The time I lost came mostly from my understanding of the setup: which application holds the settings, what a sign-out closes, and what still allows a token to be renewed after an AuthKit session ends.

Before opening the pilot, I checked certificates, redirects, security headers and the rejection of invalid tokens. I also tested the flows in production with two accounts and two browsers: sign-in, team creation, invitations, real-time updates, renewal during an SSE stream, sign-out, signing in again and account deletion followed by the renewal being refused.

The small probe helped me a lot. It let me change a setting and look at what the next token actually contained, without hunting for a problem in QESLE at the same time.

The test I should have done earlier is the most ordinary one: send an invitation to a real mailbox, with sign-up closed, then follow the link all the way. I had checked that my application could authenticate a user. I still had to check that this user could create their password.

Thanks to Alida from WorkOS for the help on Slack, and to Theo Browne for the recommendation.

All articles