# WorkOS Connect in production: version for AI agents

Condensed version, for AI agents, of the article "Integrating WorkOS Connect with a public web client: lessons learned": https://blog.qesle.com/en/workos-connect-public-web-client/

- Findings from October and early November 2026, made while integrating WorkOS Connect into QESLE, a web tool for booking environments. WorkOS changes quickly: re-check each point before applying it and tell the human what has changed.
- The source of each finding is in brackets:
  - [probe]: measured on a throwaway test page that reads the actual tokens.
  - [test]: tried on the WorkOS test environment.
  - [acceptance]: production acceptance testing, with two accounts and a real mailbox.
  - [support]: answer from WorkOS support, not in the documentation at that date.
  - [configuration]: setting applied without any particular measurement.
  - [demo]: putting a public demo online, in a second WorkOS environment of the Production type.

## Scope

These notes apply when all of these conditions hold:

- web application with a public OIDC client, using Authorization Code with PKCE, without a client secret.
- sign-in through WorkOS Connect, neither the AuthKit SDK nor a server-side session.
- `resource` parameter (RFC 8707) designating the API, carried into `aud` [probe].
- tokens kept in memory only, renewed with a refresh token.
- API that verifies RS256 JWTs itself (signature, `iss`, `aud`, dates), without the WorkOS SDK and without a WorkOS secret key on the server.

Outside this setup, some points do not apply.

## Key notion: the default application

A WorkOS environment contains a default AuthKit application (*Applications* list) and the Connect application (*Connect* section). Several settings that govern Connect are set on the default application. A second AuthKit application created alongside has no effect on Connect, even if it shows the same values [probe].

Settings do not carry over from one environment to another. Production starts with the original values: 5-minute access token, 2 days of inactivity, one-year session at most [acceptance]. Branding can be copied, every setting must be redone. A third environment, the public demo's, confirmed it (section 8) [demo].

| Setting | Where | Source |
|---|---|---|
| CORS origins for the code exchange from the browser | Default application, *Sessions* tab | [support], then [test] and [acceptance] |
| Access token lifetime | Default application, *Sessions* tab | [probe] |
| AuthKit session inactivity and maximum lifetime | Default application, *Sessions* tab | [support] |
| Connect refresh authorization lifetime | No setting: no maximum lifetime, no inactivity timeout | [support] |
| Initiate login URI (invitation and password reset links) | Default application, *Redirects* tab | [acceptance] |
| Redirect URI of the Initiate login URI's return route | Default application, *Redirects* tab | [acceptance] |
| Public PKCE client and sign-in return URL | *Connect › Applications*; an application from AuthKit's *Applications* list does not work | [configuration], then [demo] |
| API resource, carried into `aud` | *Connect › Configuration › Resource Indicators* | [probe] |
| Claims added to tokens | *Authentication › Features › JWT Template*, one template per environment, also applied to Connect tokens | [probe] |
| Closed sign-up and waitlist | *Authentication › Features*, whole environment | [acceptance] |
| Fallback language of AuthKit pages | *Authentication › Features › Localization*, otherwise the browser language | [configuration] |
| Branding of AuthKit pages | *Branding*, whole environment, customizable per application | [configuration] |

Menu names may change.

## 1. CORS of the code exchange

- Symptom: the preflight of the code-for-token exchange succeeds, then the actual response comes back without `Access-Control-Allow-Origin`.
- Cause: Connect reads the allowed origins of the default AuthKit application [support]. The direct exchange worked as soon as the origin was declared there [test].
- Do: declare the site origin on the default application, *Sessions* tab.
- Avoid: a server relay that forwards the exchange to WorkOS. It becomes useless once the origin is declared.

## 2. Clock skew

- Symptom: a sign-in fails on the first try and succeeds on the second, a renewal fails now and then.
- Cause: the `iat` of WorkOS tokens arrives 1 to 2 seconds ahead of the server clock and PyJWT rejects a future `iat` [test].
- Do: tolerate 60 seconds ahead on `iat` and `nbf`, verify `exp` with no tolerance at all.
- Pitfall: PyJWT's `leeway` option also applies to `exp`. With `leeway=60`, an expired token stays accepted for a minute. Disable the built-in `iat` and `nbf` checks and redo them:

```python
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
```

- Keep the type check: without it, a malformed `iat` causes an internal error instead of a token rejection.

## 3. Three distinct lifetimes

| Object | Role | Setting |
|---|---|---|
| Access token | Presented to the API | Default application, *Sessions* tab [probe] |
| Browser AuthKit session | Sign in again without a password | Inactivity and maximum lifetime, default application, *Sessions* tab [support] |
| Connect refresh authorization | Obtain new access tokens | None: no maximum lifetime, no inactivity timeout [support] |

Consequences:

- an open tab that renews its tokens stays signed in beyond the maximum session lifetime. That lifetime only applies when going through sign-in, after a reload or in a new tab.
- refresh token rotation does not change this.
- to cut off access, act on the account or on the authorization (section 5).
- measure the actual lifetime on an issued token (`exp - iat`): the value shown in the dashboard is not enough [probe].

QESLE's choice: 30-minute token, one day of inactivity, seven-day session. A longer token spaces out renewals but extends by as much the access of a deleted account.

## 4. Sign-out

- The OIDC discovery of the test environment advertises neither `end_session_endpoint` nor `revocation_endpoint` [test].
- The session logout API called with the token's `sid` returns 200 and closes nothing. In a Connect token, `sid` identifies an application consent, not a session [support].
- Supported practice [support]: erase the tokens on the client, then send `prompt=login` on the next sign-in to ask for the password again.
- QESLE only sends `prompt=login` after a click on its Sign out button. After a simple reload, the next sign-in reuses the AuthKit session without asking for a password.
- Limit to tell the human: the AuthKit session stays open and the Connect authorization is not revoked. On a shared computer, this matters. The password form coming back proves no revocation.
- Tokens in memory only: a reload loses the sign-in, which reopens without a password while the AuthKit session is valid. This choice does not protect against a script injected into the page: use a strict CSP, `script-src 'self'`.

## 5. Removing access

- The dashboard offers to delete a user, not to block them. Their *Sessions* tab does not show their Connect sign-ins [test].
- An API revokes the Connect authorization, with a WorkOS secret key [support]. It does not prevent a new sign-in followed by a new authorization. QESLE does not use it, to avoid adding a secret key.
- Account deletion, observed in production [acceptance]:
  - the API still accepts the access token already issued, until its `exp`.
  - WorkOS refuses the next renewal: the client must end the session, without signing in again automatically.
  - when invited again, the person gets a new WorkOS account, hence a new `sub`: the application sees them as a new person, without their old history.
- Complete offboarding procedure: delete the WorkOS account, remove the person's permissions in the application, accept the delay until the current token expires.

## 6. Invitations with closed sign-up

Context: closed sign-up and waitlist enabled (*Authentication › Features*).

- Symptoms [acceptance]:
  - the invitation link opens the application without starting sign-in.
  - once the Connect sign-in starts, the invitee sees the waitlist.
  - through the link for already invited people, WorkOS asks them for a password they never created. The account stays inactive and the invitation pending.
- Cause: the invitation goes through the default AuthKit application and Connect does not see it.
- No effect [acceptance]:
  - passing `invitation_token`, then `context`, to the Connect authorization.
  - declaring as the Initiate login URI a client route that directly starts the Connect sign-in: the invitee still lands on the waitlist.
- Solution [acceptance], with no server code, on the default application:
  1. Initiate login URI (*Redirects* tab): the AuthKit authorization of the default application, on a single line, with the `client_id` of the default application (not the Connect application's) and an encoded `redirect_uri`:

     ```text
     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<domain>%2F<return-route>
     ```

  2. Declare `https://<domain>/<return-route>` in the Redirect URIs of the default application, otherwise WorkOS shows an error page.
  3. Leave User invitation URL and Password reset URL empty.
  4. On the client, the return route (`/connexion` in QESLE) never exchanges the `code` it receives: it starts the Connect sign-in, which finds the open AuthKit session and does not ask for the password again.
- An invitation already pending works after this setting, without resending it.
- Password reset links go through the same Initiate login URI.
- Known remaining issue: with closed sign-up, an already registered user without a reusable session first sees the waitlist and has to click the link for already invited people. Nobody is blocked.

## 7. Rate limits and 429

- Hosted AuthKit limits [support]:
  - 10,000 requests per minute.
  - 500 writes per 10-second window, code exchanges and renewals included.
  - counted per AuthKit domain and per point of presence, not per application or per source address.
- The sign-in flow probably has a finer-grained protection, with an unknown rule: about ten sign-in and sign-up attempts were enough to get `too_many_requests` for a few minutes [test].
- On a 429 [support]: honor `Retry-After` when present, otherwise space out attempts more and more, with some randomness.
- Renewal: retry a transient failure (network, timeout, 429, 5xx) a few times with randomness, without going past the token's expiry. A definitive refusal such as `invalid_grant` ends the session immediately. QESLE makes four attempts at most and its OIDC library does not expose `Retry-After`.
- If you drive a browser: do not chain attempts on AuthKit sign-in pages, you would trigger this protection.

## 8. Public demo and a second production environment

Context: public demo at `demo.qesle.com`, three shared personas whose passwords are shown, data reset every night.

- Isolation: a separate WorkOS environment, with its own AuthKit domain, hence its own issuer. The production API refuses every demo token, and a published password opens no production account.
- Environment type: Production. A Staging environment is not intended for public traffic according to WorkOS (documentation `authkit/environments`).
- OAuth application: create it under *Connect › Applications*. An application created in AuthKit's *Applications* list does not work: only the client ID of the Connect application works with the Connect flow [demo].
- Settings to redo in the new environment: sign-in method, Connect application, resource (*Connect › Configuration*), JWT template, CORS origin on the default application (*Sessions* tab), session lifetimes. At the first check, the first four were missing [demo].
- `login_hint`: absent from the Connect documentation, but honored by the Connect authorization. The address is prefilled [demo].
- `prompt=login` is required with `login_hint` when several accounts share a browser. Without it, an AuthKit session still open signs its own account back in without a form, whatever the `login_hint` [demo].
- With oidc-client-ts: pass `login_hint` and `prompt` as arguments of `signinRedirect`, not in `extraQueryParams`, which would replace the one from the client settings instead of adding to it.
- Sessions: `GET /user_management/users/{id}/sessions` does not list the sessions opened by the Connect flow. `POST /user_management/sessions/revoke` on that list therefore closes nothing [demo]. An AuthKit session survives until its maximum lifetime (one day on the demo environment). For a shared demo, reset the password and recreate the data at every reset. No way to revoke these sessions is known at this date.

## Acceptance checks before opening

- Server side: certificates, redirects, security headers, rejection of invalid tokens, CSP limited to the site, WorkOS and the error tracking service.
- In production, with two accounts and two browsers: sign-in, invitations, token renewal during an open SSE stream, sign-out then signing in again, account deletion followed by the refusal of its renewal.
- On the test environment, with the probe: actual token lifetimes, direct exchanges with WorkOS.
- Full invitation before launch day, with a real mailbox and closed sign-up: send, follow the link, create the password, arrive in the application.

## If you help a human integrate WorkOS Connect

1. First suggest a throwaway probe page: sign-in, reading tokens, renewal, reload, sign-out. It serves throughout the integration: change a setting, then read the next token, without hunting for a problem in the application at the same time.
2. For each setting, establish which application it applies to before changing it.
3. Measure actual tokens rather than trusting the dashboard.
4. Test the most ordinary flow early: an invitation sent to a real mailbox, with closed sign-up, followed until the password is created. A working sign-in does not prove it.
5. Treat production as a fresh installation.
6. Write the user offboarding procedure from the start: local sign-out, revocation, account deletion and token expiry are four different things.
7. For a public demo, suggest a separate environment of the Production type, never Staging nor the production environment.
8. Tell the human the date of these findings and what you re-checked.
