Intégrer WorkOS Connect avec un client web public : retour d’expérience
Le 30 octobre, pendant la recette de production de QESLE, j’ai voulu tester une invitation envoyée depuis le tableau de bord de WorkOS. Les inscriptions étaient fermées et la liste d’attente activée. L’idée était simple : laisser les utilisateurs demander l’accès, sans permettre à n’importe qui de créer un compte.
J’ai donc envoyé une invitation à un compte de test. En ouvrant le lien reçu par e-mail, je suis tombé sur une page proposant de rejoindre la liste d’attente.
J’ai cliqué sur « Déjà invité ? Connectez-vous ici », puis renseigné l’adresse e-mail du compte. Sauf que WorkOS me demandait un mot de passe. Un mot de passe que je n’avais jamais eu l’occasion de créer. La seule option qui semblait fonctionner était de passer par « Réinitialiser le mot de passe ».
À ce moment-là, ça faisait un peu plus de deux semaines que je travaillais sur l’intégration de WorkOS. La connexion fonctionnait, le renouvellement des jetons aussi, et j’avais déjà réglé plusieurs problèmes en cours de route.
Mais j’étais passé à côté d’un cas pourtant assez basique : inviter quelqu’un alors que les inscriptions sont fermées, lui permettre de définir son mot de passe, puis d’accéder à l’application.
Dans cet article, je reviens sur les problèmes rencontrés pendant cette intégration, du réglage CORS fait au mauvais endroit jusqu’à cette invitation testée un peu tard. Et sur ce que j’ai découvert une semaine plus tard, en mettant une démo publique en ligne.
Ce retour concerne WorkOS Connect avec un client web public, en PKCE, sans secret, et une API qui vérifie elle-même les jetons. Avec le SDK AuthKit ou une session côté serveur, vous ne rencontrerez pas nécessairement les mêmes difficultés. Les observations et les réponses du support rapportées ici datent d’octobre et de début novembre 2026.
Pourquoi j’ai essayé WorkOS
QESLE, pour "Qui est sur l’env ?", permet de voir qui utilise un environnement et de le réserver. L’application tourne sur un VPS, avec un client React, une API FastAPI et PostgreSQL.
Pour l’authentification, mon exigence de départ était simple : ne pas avoir à gérer les mots de passe moi-même. Sur recommandation de Theo Browne, j’ai décidé d’essayer WorkOS.
Deux autres éléments m’intéressaient : AuthKit était gratuit jusqu’à un million d’utilisateurs actifs par mois, et je pourrais ajouter plus tard le SSO d’entreprise sans changer d’émetteur de jetons.
Avec Connect, je pouvais conserver mon propre client OIDC et demander une audience dédiée à l’API. Le montage retenu est le suivant :
- un client web public, sans secret, en Authorization Code avec PKCE ;
- le paramètre
resourcede la RFC 8707 pour désigner l’API destinataire du jeton ; - des jetons conservés en mémoire seulement, renouvelés par un jeton de rafraîchissement ;
- aucune clé secrète WorkOS sur mon serveur.
Avant de toucher à QESLE, j’ai écrit une petite page de sonde sur l’environnement de test. Elle permettait de se connecter, de lire les jetons, de les renouveler, de recharger la page et de se déconnecter. Elle m’a pris une demi-heure et m’a servi pendant toute l’intégration.
J’ai pu confirmer que PKCE fonctionnait sans secret, que l’audience reprenait bien resource et que la rotation des jetons de rafraîchissement fonctionnait. Mon API acceptait les jetons WorkOS sans modification.
La sonde a aussi révélé deux problèmes : le navigateur ne pouvait pas échanger directement le code contre un jeton, et le client n’avait aucun moyen de fermer la session WorkOS. C’est avec ces premiers constats que j’ai contacté le support sur Slack et échangé avec Alida.
1. J’avais configuré le CORS sur la mauvaise application
J’avais créé une seconde application AuthKit pour QESLE, à côté de l’application Connect, et déclaré l’origine du site dans ses réglages CORS.
Le préflight passait. La réponse à la véritable requête revenait sans en-tête Access-Control-Allow-Origin.
Pour avancer, j’ai installé un relais sur mon propre domaine, chargé de transmettre l’échange à WorkOS. J’avais même commencé à préparer son passage en production, avec une limitation de débit.
Alida m’a alors expliqué que Connect utilisait les origines autorisées de l’application AuthKit par défaut de l’environnement.
J’ai ajouté l’origine à cet endroit. L’échange direct a fonctionné et j’ai supprimé le relais le jour même, deux jours après son installation.
Je cherchais le bon réglage, mais sur une application que Connect ne consultait pas. Cette confusion allait me coûter du temps une deuxième fois.
2. Deux secondes d’écart d’horloge faisaient échouer la connexion
Il fallait parfois cliquer deux fois sur "Se connecter". Certains renouvellements échouaient aussi, sans cause visible.
Les jetons WorkOS portaient une date d’émission en avance d’une à deux secondes sur l’horloge de mon serveur. PyJWT refusait donc des jetons qu’il considérait comme émis dans le futur.
J’ai ajouté une tolérance de 60 secondes sur iat, la date d’émission, et sur nbf, le début de validité lorsqu’il est présent. J’ai conservé un contrôle strict de exp, l’expiration.
Ce dernier point demande un peu d’attention avec PyJWT : son option leeway s’applique aussi à exp. Un leeway=60 autoriserait donc également un jeton expiré depuis moins d’une minute.
Pour obtenir le comportement souhaité, j’ai désactivé les contrôles intégrés de iat et nbf, puis effectué ces vérifications séparément. Le principe est le suivant, avec l’audience et l’émetteur configurés pour l’API :
import time
import jwt # PyJWT
TOLERANCE = 60 # secondes, pour iat et nbf seulement
def verifier_jeton(jeton: str, cle_publique) -> dict:
claims = jwt.decode(
jeton,
cle_publique,
algorithms=["RS256"],
audience=AUDIENCE_API,
issuer=EMETTEUR_WORKOS,
options={
"require": ["exp", "iat"],
"verify_iat": False,
"verify_nbf": False,
},
) # exp reste vérifié, sans tolérance
limite = time.time() + TOLERANCE
for nom in ("iat", "nbf"):
if nom not in claims:
continue
valeur = claims[nom]
if type(valeur) not in (int, float):
raise jwt.InvalidTokenError(f"{nom} doit être un nombre")
if valeur > limite:
raise jwt.ImmatureSignatureError(f"{nom} est dans le futur")
return claims
La vérification du type évite notamment qu’une valeur mal formée provoque une erreur interne au lieu d’un refus du jeton.
3. La durée de session ne limitait pas le temps passé dans l’application
J’avais réglé la durée du jeton d’accès, le délai d’inactivité et la durée maximale de session. Je pensais avoir limité le temps pendant lequel quelqu’un pouvait rester connecté.
Mais ces réglages concernent trois choses différentes :
- Le jeton d’accès est présenté à mon API. Il expire au bout de 30 minutes dans ma configuration.
- La session AuthKit du navigateur permet de repasser par la connexion sans saisir à nouveau son mot de passe. Elle possède un délai d’inactivité et une durée maximale.
- L’autorisation de rafraîchissement Connect permet d’obtenir de nouveaux jetons d’accès. Selon Alida, elle n’avait ni durée maximale ni délai d’inactivité, et aucun réglage ne permettait d’en fixer.
Un onglet resté ouvert peut donc continuer à renouveler ses jetons après l’expiration de la session AuthKit. La rotation des jetons de rafraîchissement ne change pas cette situation.
Dans QESLE, les limites de la session AuthKit redeviennent pertinentes lorsque la personne repasse par la connexion, par exemple après un rechargement de page, puisque les jetons de l’application sont seulement en mémoire.
J’ai aussi découvert que la durée du jeton et les limites de session se réglaient sur l’application AuthKit par défaut.
Pendant une semaine, j’ai cru que ma seconde application pilotait la durée du jeton. Les deux applications affichaient les mêmes valeurs, ce qui masquait mon erreur. Pour vérifier, j’ai réglé cinq minutes sur l’application par défaut uniquement. Le jeton suivant durait cinq minutes.
Enfin, ces réglages n’avaient pas suivi en production. J’y ai retrouvé les valeurs d’origine : cinq minutes pour le jeton, deux jours d’inactivité et un an de session au maximum.
J’ai retenu 30 minutes pour le jeton, un jour d’inactivité et sept jours de session. Les 30 minutes espacent les renouvellements et donc les réouvertures du flux temps réel de QESLE. En contrepartie, un jeton déjà émis peut rester utilisable pendant tout ce délai après la suppression du compte.
4. "Se déconnecter" ne fermait pas la session WorkOS
Dans QESLE, la déconnexion efface les jetons de l’application. Elle ne ferme pas la session AuthKit du navigateur et ne révoque pas l’autorisation Connect.
J’ai cherché comment aller plus loin. La découverte OIDC de mon environnement de test n’annonçait ni end_session_endpoint ni revocation_endpoint. J’ai aussi essayé l’appel de déconnexion de session de WorkOS avec le sid du jeton : il répondait 200, mais ne fermait rien.
Alida m’a expliqué pourquoi : dans un jeton Connect, ce sid désigne un consentement d’application, pas une session.
La pratique qu’elle m’a indiquée consiste à déconnecter localement, puis à envoyer prompt=login à la connexion suivante pour redemander le mot de passe. C’est le comportement retenu dans QESLE après un clic sur "Se déconnecter".
Le retour du formulaire ne signifie pas que l’ancienne session ou l’autorisation a été révoquée. Sur un poste partagé, il faut en tenir compte : la session AuthKit reste ouverte après la déconnexion de QESLE.
Un simple rechargement de page produit un comportement différent. Il fait perdre les jetons en mémoire et ramène à l’accueil, mais le clic suivant sur "Se connecter" peut réutiliser la session AuthKit sans demander de mot de passe.
J’accepte ce compromis pour ne conserver aucun jeton dans le stockage du navigateur. Cela ne protège pas pour autant d’un script malveillant exécuté dans la page. Ma première défense contre ce risque est une CSP stricte, avec script-src 'self', qui autorise seulement les scripts du site.
5. Supprimer un compte ne coupait pas immédiatement son accès
Pour le pilote, il me fallait une procédure permettant de retirer l’accès à quelqu’un.
Lors de mes essais, la fiche utilisateur du tableau de bord WorkOS proposait la suppression du compte, mais pas son blocage. L’onglet "Sessions" n’affichait pas ses connexions Connect.
Alida m’a indiqué une API capable de révoquer l’autorisation Connect. Elle demande une clé secrète WorkOS. Je ne l’ai pas retenue pour l’instant : elle ajouterait cette clé à mon outillage et, à elle seule, la révocation n’empêcherait pas la personne de se reconnecter pour autoriser de nouveau l’application.
J’ai donc testé la suppression du compte en production :
- mon API continuait d’accepter le jeton d’accès déjà émis, jusqu’à son expiration ;
- WorkOS refusait le renouvellement suivant ;
- QESLE affichait "Votre session a expiré", sans tenter de reconnexion automatique.
Avec mon réglage, le délai pouvait atteindre 30 minutes.
La suppression chez WorkOS ne retirait pas non plus les appartenances dans QESLE. La personne restait membre de ses équipes et son historique restait visible selon les droits habituels. Une procédure de départ complète doit donc aussi retirer ses accès aux équipes dans QESLE.
Si cette personne revient plus tard sur invitation, WorkOS lui crée un compte avec un nouvel identifiant. QESLE la considère alors comme une nouvelle personne, sans rattachement automatique à son ancien historique.
C’est la procédure que j’ai retenue pour le pilote, avec ces limites.
6. L’invitation se perdait entre AuthKit et mon parcours Connect
Revenons au compte de test du 30 octobre.
Le lien reçu par e-mail aurait dû ouvrir l’écran d’acceptation de l’invitation, avec les champs prénom, nom et mot de passe. Dans mon parcours, il menait d’abord à l’accueil de QESLE, sans lancer de connexion. Une fois celle-ci lancée, l’invité arrivait sur la liste d’attente. Puis "Déjà invité ? Connectez-vous ici" lui demandait un mot de passe qu’il n’avait jamais défini.
Dans le tableau de bord, son compte restait inactif et son invitation en attente.
Le parcours d’invitation passait par l’application AuthKit par défaut. Ma connexion, elle, repartait par Connect, sans retrouver le contexte de cette invitation.
J’ai essayé trois approches dans la journée.
D’abord, j’ai ajouté une route /connexion dans le client. Elle lance la connexion dès son ouverture. Déclarée comme Initiate login URI, elle supprimait le premier détour par l’accueil. Mais elle lançait toujours Connect, et l’invité retrouvait la liste d’attente.
J’ai ensuite essayé de transmettre l’invitation à Connect avec invitation_token, puis context. Ces paramètres n’ont pas été pris en compte dans mes essais.
Le parcours a finalement fonctionné en laissant l’application AuthKit par défaut traiter elle-même l’invitation, avant de lancer Connect.
Son Initiate login URI pointe désormais vers sa propre autorisation AuthKit, avec provider=authkit et un retour sur /connexion :
https://api.workos.com/user_management/authorize?client_id=<client_id de l’application par défaut>&provider=authkit&response_type=code&redirect_uri=https%3A%2F%2F<votre-domaine>%2Fconnexion
Il faut utiliser l’identifiant client de l’application par défaut, pas celui de Connect, et encoder la valeur de redirect_uri.
Le parcours devient le suivant :
- Le lien d’invitation passe par l’
Initiate login URIde l’application par défaut. - Cette adresse lance son autorisation AuthKit.
- La personne renseigne son prénom, son nom et son mot de passe. Une session AuthKit s’ouvre.
- AuthKit revient sur
/connexionavec un code que QESLE n’échange pas : à ce stade, seule l’ouverture de la session nous intéresse. /connexionlance le parcours Connect de QESLE, qui retrouve cette session et ne redemande pas le mot de passe.
J’ai aussi dû ajouter /connexion aux Redirect URIs de l’application par défaut. Sans cette déclaration, WorkOS affichait une erreur.
Dans le même onglet, j’ai laissé User invitation URL et Password reset URL vides. C’est l’Initiate login URI qui assure ce parcours, sans code serveur supplémentaire.
Je n’ai pas eu besoin de renvoyer l’invitation. Le lien encore en attente a suffi : la personne a créé son mot de passe et est arrivée dans QESLE. Les liens de réinitialisation du mot de passe passent également par cet Initiate login URI.
Il reste un défaut d’ergonomie. Avec l’inscription fermée, une personne déjà inscrite qui se connecte sans session réutilisable, ou après une déconnexion explicite, voit elle aussi d’abord la liste d’attente. Elle doit choisir "Déjà invité ? Connectez-vous ici".
Le parcours fonctionne, mais ce détour mérite encore d’être corrigé. Ma prochaine piste est de faire passer l’entrée de connexion par l’application par défaut, qui permet de demander directement l’écran de connexion. Je ne l’avais pas encore validée au moment d’écrire cet article.
7. Les limites annoncées n’expliquaient pas tous les 429
Le support m’a communiqué les plafonds suivants pour AuthKit hébergé, que je n’avais pas trouvés dans la documentation :
- 10 000 requêtes par minute ;
- 500 écritures par tranche de dix secondes, échanges de code et renouvellements compris.
Selon cette réponse, les budgets sont comptés par domaine AuthKit et par point de présence, pas par application ni par adresse source.
Pour QESLE, ces chiffres laissaient beaucoup de marge. J’ai pourtant rencontré des erreurs too_many_requests pendant quelques minutes sur l’environnement de test.
Une vérification d’e-mail terminée depuis le lien du message avait perdu le retour vers QESLE. En essayant de reprendre le parcours, une dizaine de tentatives de connexion et d’inscription ont suffi à déclencher les erreurs. Je soupçonne une protection plus fine du parcours de connexion, mais je n’en connais pas la règle.
Face à un 429, le support conseille de respecter Retry-After lorsqu’il est présent. Sinon, les tentatives doivent être progressivement espacées, avec un peu d’aléa.
Le client QESLE respecte déjà cet en-tête pour la reconnexion de son flux temps réel. Pour le renouvellement WorkOS, il réessaie un échec passager, 429 compris, en quatre tentatives au plus, en espaçant les demandes avec un peu d’aléa et sans dépasser l’échéance du jeton. Il ne peut pas y suivre Retry-After, que la bibliothèque OIDC n’expose pas.
Un refus définitif comme invalid_grant met immédiatement fin à la session.
8. Une démo publique m’a fait tout régler une troisième fois
Début novembre, j’ai ouvert une démo publique de QESLE sur demo.qesle.com. Trois personnages partagés, Alice, Bob et Claire, s’y connectent avec des mots de passe donnés sur la page d’accueil, et les données sont remises à zéro chaque nuit.
Un mot de passe publié ne devait ouvrir aucun compte de la production. La démo a donc son propre environnement WorkOS, avec son propre domaine AuthKit et donc son propre émetteur : l’API de production refuse tous ses jetons.
Un environnement de type Staging aurait semblé naturel, mais WorkOS ne le destine pas au trafic public. La démo utilise un second environnement de type Production.
L’application OAuth se crée dans la section Connect. Une première application avait été créée dans la liste Applications d’AuthKit. Elle ne convenait pas : seul l’identifiant client d’une application de Connect › Applications fonctionne avec le parcours Connect de QESLE. Je l’ai supprimée.
Rien ne suit d’un environnement à l’autre. Au premier contrôle de l’environnement de la démo, il manquait la connexion par e-mail et mot de passe, l’application Connect, la ressource de l’API dans Connect › Configuration et le modèle JWT qui ajoute le nom affiché. L’origine CORS, elle, était bien sur l’application par défaut, onglet Sessions. C’est la leçon de la section 3 : un nouvel environnement se règle comme une installation neuve.
login_hint fonctionne avec Connect. La page de la démo propose un bouton "Se connecter" par personnage. Il copie le mot de passe, puis ouvre AuthKit avec l’adresse du personnage déjà saisie, grâce au paramètre login_hint de l’autorisation. Je ne l’ai pas trouvé dans la documentation de Connect, mais WorkOS le prend en compte.
Il faut l’accompagner de prompt=login. Sans lui, si la session AuthKit d’Alice est encore ouverte dans le navigateur, le bouton de Bob reconnecte Alice sans afficher de formulaire. Avec prompt=login, le formulaire s’affiche à chaque fois, prérempli avec l’adresse de Bob.
Avec oidc-client-ts, ces deux paramètres passent par les arguments de la redirection :
await gestionnaire.signinRedirect({
login_hint: 'bob@demo.qesle.com',
prompt: 'login',
});
Je ne les ai pas mis dans extraQueryParams : un extraQueryParams donné à la redirection remplacerait celui des réglages du client au lieu de s’y ajouter.
Les sessions Connect n’apparaissent pas dans l’API des sessions. Chaque nuit, le script de remise à zéro devait aussi fermer les sessions des personnages, par GET /user_management/users/{id}/sessions puis POST /user_management/sessions/revoke. Sur la machine de la démo, la liste revient sans les sessions ouvertes par le parcours Connect, et rien n’est révoqué. C’est la même lacune que l’onglet "Sessions" du tableau de bord, vue à la section 5.
Une session AuthKit ouverte survit donc à la remise à zéro, jusqu’à sa durée maximale, que j’ai ramenée à un jour sur cet environnement. Pour la démo, la protection vient d’ailleurs : chaque nuit, le mot de passe est remis et les données sont recréées. Je cherche encore un moyen de révoquer ces sessions.
Les réglages que j’aurais aimé repérer dès le départ
Je garde ce tableau comme aide-mémoire. Les menus du tableau de bord peuvent évoluer.
| Réglage | Emplacement | Vérification |
|---|---|---|
| Origines CORS utilisées pour l’échange de jetons | Application AuthKit par défaut → Sessions | Explication d’Alida, puis essais en test et en production |
| Durée du jeton d’accès | Application par défaut → Sessions | Sonde avec une durée modifiée sur cette seule application |
| Inactivité et durée maximale de session AuthKit | Application par défaut → Sessions | Support |
| Durée de l’autorisation de rafraîchissement Connect | Aucun réglage disponible ; aucune durée maximale ni délai d’inactivité selon Alida | Support |
Initiate login URI des invitations et réinitialisations |
Application par défaut → Redirects | Recette de production |
URI de retour /connexion du parcours d’invitation |
Application par défaut → Redirects | Recette de production |
| Client public PKCE et URI de retour de Connect | Connect → Applications ; une application de la liste Applications d’AuthKit ne convient pas | Configuration de production, puis démo |
| Ressource de l’API, reprise dans l’audience | Connect → Configuration → Resource Indicators | Sonde |
| Claims supplémentaires, comme le nom affiché | Authentication → Features → JWT Template ; modèle commun à l’environnement, également appliqué aux jetons Connect | Sonde |
| Inscription fermée et liste d’attente | Authentication → Features, pour tout l’environnement | Recette de production |
| Langue de repli des pages AuthKit | Authentication → Features → Localization ; sinon, langue du navigateur | Configuration |
| Apparence des pages AuthKit | Branding, avec personnalisation possible par application ; à recopier entre environnements | Configuration |
| Adresse préremplie à la connexion | login_hint à l’autorisation Connect, avec prompt=login ; non documenté pour Connect |
Démo publique |
| Environnement d’une démo publique | Environnement distinct, de type Production ; Staging n’est pas destiné au trafic public | Documentation WorkOS |
La seconde application AuthKit de mon environnement de test ne m’a finalement servi à rien. En production, la liste Applications contient seulement l’application par défaut ; l’application Connect se trouve dans la section Connect. Dans l’environnement de la démo, une application OAuth créée dans la liste Applications n’a pas fonctionné non plus.
Ce que je retiens de l’intégration
J’ai obtenu une API indépendante du fournisseur d’identité : elle vérifie des jetons signés en RS256, leur signature, leur émetteur, leur audience et leurs dates de validité, sans embarquer de SDK WorkOS.
WorkOS m’a évité d’écrire et de maintenir la gestion des mots de passe. Les pages de connexion s’affichent en français pour un navigateur en français, et le support m’a donné des réponses précises sur des comportements que je n’avais pas trouvés dans la documentation.
Le temps perdu venait surtout de ma compréhension du montage : quelle application porte les réglages, ce que ferme une déconnexion, et ce qui permet encore de renouveler un jeton après la fin d’une session AuthKit.
Avant d’ouvrir le pilote, j’ai vérifié les certificats, les redirections, les en-têtes de sécurité et le refus des jetons invalides. J’ai aussi testé les parcours en production avec deux comptes et deux navigateurs : connexion, création d’équipe, invitations, mises à jour en temps réel, renouvellement pendant un flux SSE, déconnexion, reconnexion et suppression de compte suivie du refus de renouvellement.
La petite sonde m’a beaucoup aidé. Elle me permettait de modifier un réglage et de regarder ce que contenait réellement le jeton suivant, sans chercher en même temps un problème dans QESLE.
Le test que j’aurais dû avancer est pourtant le plus ordinaire : envoyer une invitation à une vraie boîte mail, avec l’inscription fermée, puis suivre le lien jusqu’au bout. J’avais vérifié que mon application savait authentifier un utilisateur. Il me restait à vérifier que cet utilisateur pouvait créer son mot de passe...
Merci à Alida de WorkOS pour son aide sur Slack, et à Theo Browne pour la recommandation.