Blog · November 2026 · 8 min read Blog · Novembre 2026 · 8 min de lectureالمدونة · نوفمبر 2026 · 8 دقائق قراءة
A single sign-on where the browser never sees a token Un SSO où le navigateur ne voit jamais un tokenدخول موحَّد لا يرى فيه المتصفح أي رمز
What I learned building an OAuth2 / OpenID Connect single sign-on during my internship at Mobilis: PKCE, the Backend-for-Frontend pattern, and the race condition hidden in refresh token rotation. Ce que j'ai appris en construisant un SSO OAuth2 / OpenID Connect pendant mon stage chez Mobilis : PKCE, le pattern Backend-for-Frontend, et la situation de concurrence cachée dans la rotation des refresh tokens.ما تعلّمته وأنا أبني نظام دخول موحَّد بـ OAuth2 / OpenID Connect خلال تربصي في موبيليس: PKCE، ونمط Backend-for-Frontend، ومشكلة التزامن المخفية في تدوير رموز التحديث.
The usual approach, and its weak spotL'approche habituelle, et son point faibleالطريقة المعتادة، ونقطة ضعفها
In many single-page apps, the front end runs the OAuth flow itself and keeps the access token and refresh token in localStorage. It works, but any script running on the page can read them. One XSS bug, one compromised npm package, and the attacker leaves with a refresh token they can use from anywhere, for days.
Dans beaucoup d'applications monopage, le front fait lui-même le parcours OAuth et garde l'access token et le refresh token dans le localStorage. Ça marche, mais n'importe quel script de la page peut les lire. Une faille XSS, un paquet npm compromis, et l'attaquant repart avec un refresh token utilisable de n'importe où, pendant des jours.في كثير من التطبيقات أحادية الصفحة، تتولى الواجهة بنفسها مسار OAuth وتحفظ رمز الوصول ورمز التحديث في localStorage. هذا يعمل، لكن أي سكربت في الصفحة يستطيع قراءتهما. ثغرة XSS واحدة، أو حزمة npm مخترقة، فيغادر المهاجم برمز تحديث يستعمله من أي مكان، لأيام.
For our project, two of us built an SSO server with ASP.NET Core and OpenIddict, plus a client application. I wanted the client to follow a simple rule: the browser never holds a token. Pour notre projet, à deux, nous avons construit un serveur SSO avec ASP.NET Core et OpenIddict, et une application cliente. Je voulais que le client respecte une règle simple : le navigateur ne détient jamais de token.في مشروعنا، بنينا نحن الاثنين خادم SSO بـ ASP.NET Core و OpenIddict، وتطبيقاً عميلاً. أردت أن يحترم العميل قاعدة بسيطة: المتصفح لا يحمل أي رمز أبداً.
The Backend-for-Frontend patternLe pattern Backend-for-Frontendنمط Backend-for-Frontend
The idea is to put a small server, the BFF, between the browser and the identity server. The BFF runs the OAuth flow, keeps the tokens in its own memory, and gives the browser only a session cookie. L'idée est de placer un petit serveur, le BFF, entre le navigateur et le serveur d'identité. Le BFF fait le parcours OAuth, garde les tokens dans sa propre mémoire, et ne donne au navigateur qu'un cookie de session.الفكرة هي وضع خادم صغير، الـ BFF، بين المتصفح وخادم الهوية. يتولى الـ BFF مسار OAuth، ويحفظ الرموز في ذاكرته، ولا يعطي المتصفح سوى cookie للجلسة.
The cookie is HttpOnly, so JavaScript cannot read it, and it does not even contain a token: just an opaque session id. A script that somehow got hold of it would have a random string that means nothing outside our server.
Le cookie est HttpOnly, donc JavaScript ne peut pas le lire, et il ne contient même pas de token : seulement un identifiant de session opaque. Un script qui réussirait à le récupérer n'aurait qu'une chaîne aléatoire, sans valeur en dehors de notre serveur.الـ cookie من نوع HttpOnly، فلا يستطيع JavaScript قراءته، ولا يحتوي حتى على رمز: مجرد معرّف جلسة غير قابل للقراءة. وأي سكربت ينجح في الحصول عليه لن يجد سوى سلسلة عشوائية لا قيمة لها خارج خادمنا.
Step 1: starting the sign-in with PKCEÉtape 1 : lancer la connexion avec PKCEالخطوة 1: بدء تسجيل الدخول مع PKCE
PKCE protects the authorization code: if someone intercepts it on the way back, they cannot exchange it without a secret that never left our server. The BFF generates a random verifier, sends only its SHA-256 hash (the challenge) to the SSO server, and keeps the verifier for later. PKCE protège le code d'autorisation : si quelqu'un l'intercepte au retour, il ne peut pas l'échanger sans un secret qui n'a jamais quitté notre serveur. Le BFF génère un verifier aléatoire, n'envoie au serveur SSO que son hash SHA-256 (le challenge), et garde le verifier pour plus tard.يحمي PKCE رمز التفويض: إذا اعترضه أحد في طريق العودة، لا يستطيع استبداله دون سرّ لم يغادر خادمنا قط. يولّد الـ BFF قيمة verifier عشوائية، ولا يرسل إلى خادم SSO إلا الـ hash الخاص بها بـ SHA-256 (الـ challenge)، ويحتفظ بالـ verifier لوقت لاحق.
public static string GenerateCodeVerifier()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Base64UrlEncode(bytes);
}
public static string GenerateCodeChallenge(string verifier)
{
var bytes = SHA256.HashData(Encoding.ASCII.GetBytes(verifier));
return Base64UrlEncode(bytes);
}
The verifier and a random state go into two short-lived cookies (10 minutes, HttpOnly), then the user is redirected to the SSO server with code_challenge_method=S256. On the server side, PKCE is not optional: OpenIddict is configured with RequireProofKeyForCodeExchange(), so a client that skips it is refused.
Le verifier et un state aléatoire vont dans deux cookies de courte durée (10 minutes, HttpOnly), puis l'utilisateur est redirigé vers le serveur SSO avec code_challenge_method=S256. Côté serveur, PKCE n'est pas optionnel : OpenIddict est configuré avec RequireProofKeyForCodeExchange(), donc un client qui s'en passe est refusé.يوضع الـ verifier وقيمة state عشوائية في ملفي cookie قصيري المدة (10 دقائق، HttpOnly)، ثم يُحوَّل المستخدم إلى خادم SSO مع code_challenge_method=S256. وفي جهة الخادم، PKCE ليس اختيارياً: OpenIddict مضبوط بـ RequireProofKeyForCodeExchange()، فيُرفض أي عميل يتجاهله.
Step 2: the callback, and not trusting the id_token blindlyÉtape 2 : le retour, sans faire confiance aveuglément à l'id_tokenالخطوة 2: العودة، دون ثقة عمياء في الـ id_token
When the user comes back, the BFF checks that the state matches the cookie (this blocks forged callbacks), then exchanges the code for tokens, sending its client secret and the PKCE verifier.
Quand l'utilisateur revient, le BFF vérifie que le state correspond au cookie (ça bloque les retours forgés), puis échange le code contre les tokens, en envoyant son secret client et le verifier PKCE.عندما يعود المستخدم، يتحقق الـ BFF من أن الـ state يطابق الـ cookie (هذا يمنع العودات المزوّرة)، ثم يستبدل الرمز بالـ tokens، مرسلاً سرّه كعميل والـ verifier الخاص بـ PKCE.
A first version simply decoded the id_token and read the user's email and roles from it. That is a real mistake: decoding is not validating. The final version checks the signature with the SSO server's public keys (JWKS), the issuer, the audience and the expiry: Une première version se contentait de décoder l'id_token pour y lire l'e-mail et les rôles. C'est une vraie erreur : décoder n'est pas valider. La version finale vérifie la signature avec les clés publiques du serveur SSO (JWKS), l'émetteur, l'audience et l'expiration :كانت نسخة أولى تكتفي بفك ترميز الـ id_token لقراءة البريد الإلكتروني والأدوار. وهذا خطأ حقيقي: فك الترميز ليس تحققاً. النسخة النهائية تتحقق من التوقيع بالمفاتيح العامة لخادم SSO (JWKS)، ومن الجهة المُصدِرة، والجمهور، وتاريخ الانتهاء:
var discovery = await oidcConfig.GetConfigurationAsync(ct);
var parameters = new TokenValidationParameters
{
ValidIssuer = discovery.Issuer,
ValidAudience = config["Sso:ClientId"],
IssuerSigningKeys = discovery.SigningKeys, // JWKS, fetched and cached
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidateLifetime = true
};
var user = handler.ValidateToken(tokens.IdToken, parameters, out _);
Only then does the BFF store the tokens in its memory, under a new random session id (32 bytes), and sign the user in with a cookie that carries that id and the user's claims. Seulement ensuite, le BFF range les tokens dans sa mémoire, sous un nouvel identifiant de session aléatoire (32 octets), et connecte l'utilisateur avec un cookie qui porte cet identifiant et les informations de l'utilisateur.بعد ذلك فقط، يحفظ الـ BFF الرموز في ذاكرته، تحت معرّف جلسة عشوائي جديد (32 بايت)، ويُدخل المستخدم بـ cookie يحمل هذا المعرّف ومعلومات المستخدم.
Step 3: refresh token rotation, and the race conditionÉtape 3 : la rotation des refresh tokens, et la situation de concurrenceالخطوة 3: تدوير رموز التحديث، ومشكلة التزامن
Access tokens are short-lived, so the BFF renews them before they expire: on each authenticated request, if the token expires in less than 60 seconds, it uses the refresh token. Our SSO server rotates refresh tokens: each one is meant to be used once, and every refresh returns a new one. Les access tokens ont une durée de vie courte, donc le BFF les renouvelle avant qu'ils expirent : à chaque requête authentifiée, si le token expire dans moins de 60 secondes, il utilise le refresh token. Notre serveur SSO fait tourner les refresh tokens : chacun est fait pour servir une fois, et chaque renouvellement en renvoie un nouveau.رموز الوصول قصيرة العمر، لذا يجدّدها الـ BFF قبل انتهائها: عند كل طلب موثَّق، إذا بقي على انتهاء الرمز أقل من 60 ثانية، يستعمل رمز التحديث. وخادم SSO لدينا يدوّر رموز التحديث: كل رمز مخصّص لاستعمال واحد، وكل تجديد يعيد رمزاً جديداً.
That is where I hit a subtle problem. A page often sends several requests at the same time. If they all see an almost-expired token, they all try to refresh with the same refresh token. The first one gets a new pair; the others send a token that is already used up, and the session can break for no good reason. C'est là que je suis tombé sur un problème subtil. Une page envoie souvent plusieurs requêtes en même temps. Si elles voient toutes un token presque expiré, elles essaient toutes de le renouveler avec le même refresh token. La première obtient une nouvelle paire ; les autres envoient un token déjà utilisé, et la session peut casser sans raison.هنا صادفت مشكلة دقيقة. كثيراً ما ترسل الصفحة عدة طلبات في الوقت نفسه. إذا رأت كلها رمزاً قارب على الانتهاء، تحاول كلها تجديده بـنفس رمز التحديث. يحصل الأول على زوج جديد؛ ويرسل الباقون رمزاً مستعملاً، فقد تنقطع الجلسة دون سبب.
The fix is a lock per session, plus a second check once the lock is acquired, because another request may have refreshed while we were waiting: La solution : un verrou par session, plus une deuxième vérification une fois le verrou obtenu, parce qu'une autre requête a peut-être déjà renouvelé pendant qu'on attendait :الحل: قفل لكل جلسة، مع تحقق ثانٍ بعد الحصول على القفل، لأن طلباً آخر ربما جدّد الرمز أثناء الانتظار:
var semaphore = _locks.GetOrAdd(sessionId, _ => new SemaphoreSlim(1, 1));
await semaphore.WaitAsync();
try
{
// Someone may have refreshed while we were waiting.
tokens = _store.Get(sessionId);
if (tokens.ExpiresAt - DateTimeOffset.UtcNow > RefreshMargin)
return TokenRefreshOutcome.Valid;
// ...only one request per session reaches this point
// and calls /connect/token with grant_type=refresh_token
}
finally
{
semaphore.Release();
}
Failing well: not every error means "sign out"Bien gérer les pannes : toute erreur ne veut pas dire « déconnexion »التعامل الجيد مع الأعطال: ليس كل خطأ يعني « تسجيل الخروج »
The refresh can fail for two very different reasons, so the code returns three outcomes instead of a boolean: Le renouvellement peut échouer pour deux raisons très différentes, donc le code renvoie trois résultats au lieu d'un booléen :قد يفشل التجديد لسببين مختلفين تماماً، لذا تُرجع الشيفرة ثلاث نتائج بدل قيمة منطقية واحدة:
Valid: carry on.Valid: on continue.Valid: نواصل.ReauthRequired: the SSO server refused the refresh token (revoked, expired, account disabled). The session is really over: the BFF signs the user out and answers 401.ReauthRequired: le serveur SSO a refusé le refresh token (révoqué, expiré, compte désactivé). La session est vraiment finie : le BFF déconnecte l'utilisateur et répond 401.ReauthRequired: رفض خادم SSO رمز التحديث (ملغى، منتهي الصلاحية، حساب معطَّل). انتهت الجلسة فعلاً: يُخرج الـ BFF المستخدم ويرد بـ 401.TransientFailure: the network or the SSO server is down for a moment. Signing everyone out because of a 5-second outage would be worse than the outage, so the request continues with the current token.TransientFailure: le réseau ou le serveur SSO est indisponible un instant. Déconnecter tout le monde pour une panne de 5 secondes serait pire que la panne, donc la requête continue avec le token actuel.TransientFailure: الشبكة أو خادم SSO غير متاح للحظة. إخراج الجميع بسبب عطل من 5 ثوانٍ أسوأ من العطل نفسه، لذا يستمر الطلب بالرمز الحالي.
What I would improveCe que j'amélioreraisما كنت سأحسّنه
- Tokens live in the BFF's in-memory cache: a restart signs everyone out, and two BFF instances would not share sessions. A shared store such as Redis would fix both.Les tokens vivent dans le cache mémoire du BFF : un redémarrage déconnecte tout le monde, et deux instances du BFF ne partageraient pas les sessions. Un stockage partagé comme Redis réglerait les deux.تعيش الرموز في الذاكرة المؤقتة للـ BFF: إعادة التشغيل تُخرج الجميع، ونسختان من الـ BFF لن تتقاسما الجلسات. تخزين مشترك مثل Redis يحل المشكلتين.
- The session cookie must be marked
Secureonce the app runs over HTTPS in production.Le cookie de session doit être marquéSecuredès que l'appli tourne en HTTPS en production.يجب وسم cookie الجلسة بـSecureبمجرد تشغيل التطبيق عبر HTTPS في الإنتاج. - Roles are copied into the cookie at sign-in, so a role change only applies at the next sign-in. And signing out of one app does not yet sign you out of all of them (single logout).Les rôles sont copiés dans le cookie à la connexion, donc un changement de rôle ne s'applique qu'à la connexion suivante. Et se déconnecter d'une appli ne déconnecte pas encore de toutes les autres (déconnexion unique).تُنسخ الأدوار في الـ cookie عند تسجيل الدخول، فلا يُطبَّق تغيير الدور إلا عند الدخول التالي. كما أن الخروج من تطبيق لا يُخرج بعد من كل التطبيقات الأخرى (الخروج الموحَّد).
TakeawaysÀ retenirالخلاصة
- If the browser has nothing worth stealing, a whole class of attacks becomes much less dangerous.Si le navigateur n'a rien qui vaille la peine d'être volé, toute une famille d'attaques devient beaucoup moins dangereuse.إذا لم يكن في المتصفح ما يستحق السرقة، تصبح عائلة كاملة من الهجمات أقل خطورة بكثير.
- Decoding a JWT is not validating it.Décoder un JWT, ce n'est pas le valider.فك ترميز JWT ليس تحققاً منه.
- Token rotation is great for security, but it turns concurrent requests into a problem you have to design for.La rotation des tokens est excellente pour la sécurité, mais elle transforme les requêtes simultanées en un problème à prévoir.تدوير الرموز ممتاز للأمن، لكنه يحوّل الطلبات المتزامنة إلى مشكلة يجب التحسّب لها.
The full code is on GitHub: the SSO server in ShopAuth and the BFF client in ShopWebApp. More about the project in the case study. Le code complet est sur GitHub : le serveur SSO dans ShopAuth et le client BFF dans ShopWebApp. Plus de détails sur le projet dans l'étude de cas.الشيفرة كاملة على GitHub: خادم SSO في ShopAuth والعميل BFF في ShopWebApp. ومزيد من التفاصيل عن المشروع في دراسة الحالة.