الأدلة التقنية

كيف يتذكرك الموقع بعد تسجيل الدخول؟ الجلسات وJWT

بروتوكول HTTP لا يتذكر أحداً، فكيف يبقى المستخدم مسجلاً بين الصفحات، ومتى تختار الجلسة على السيرفر ومتى تختار JWT.

كيف يتذكرك الموقع بعد تسجيل الدخول؟ الجلسات وJWT

تفتح متجراً إلكترونياً وتضيف كتاباً إلى السلة، ثم تنتقل إلى صفحة أخرى فتجد الكتاب ما زال في السلة، وتسجل الدخول مرة واحدة في الصباح فيبقى اسمك أعلى الصفحة طوال اليوم، وهذا يبدو أمراً طبيعياً جداً حتى تعرف أن البروتوكول الذي يحمل هذه الصفحات، أي HTTP، لا يتذكر أي شيء بين طلب وآخر. والذي يصنع هذه الذاكرة ثلاثة أشياء تسمعها في كل نقاش عن تسجيل الدخول: ملفات تعريف الارتباط Cookies، والجلسات Sessions، والتوكنات Tokens وأشهرها JWT.

وفي مقال OAuth وOpenID Connect ومقال إدارة الهوية والوصول IAM تكلمنا عن من يثبت هوية المستخدم ومن يعطيه الصلاحيات، وهذا المقال يرجع خطوة إلى الأساس الذي تقوم عليه كل تلك الأنظمة: بعد أن يثبت المستخدم هويته مرة، كيف يتذكره التطبيق في الطلبات التالية؟ وسوف نناقش في هذا المقال ما يلي:

  • لماذا لا يتذكر HTTP المستخدم، والحلول البسيطة التي تخطر على البال أولاً ولماذا تفشل.
  • ملفات تعريف الارتباط: الترويستان Set-Cookie وCookie، وكل خاصية من خصائصها، وملفات الطرف الثالث وما حدث لها في المتصفحات.
  • الجلسة على السيرفر Server-Side Session: كيف تبدأ وتنتهي، ولماذا يتغير معرفها بعد الدخول، وكيف تعمل مع أكثر من نسخة من التطبيق.
  • التوكنات وJWT: لماذا ظهرت، ومم يتكون التوكن، وخوارزميات التوقيع، ومشكلة الإلغاء Revocation، والأخطاء التي تكررت في المكتبات.
  • مقارنة صادقة بين الجلسات وJWT، وأين تحفظ ما يثبت الدخول في المتصفح، وكيف تتعامل مع XSS وCSRF.
  • تجربة عملية بتطبيق Node.js صغير نزور فيه ملف تعريف ارتباط، ونبني جلسة صحيحة، ونصدر JWT ونعبث به، ثم نشغل نسختين من التطبيق مع Valkey.
  • قائمة بأفضل الممارسات والأخطاء الشائعة.

HTTP لا يتذكرك: كل طلب يصل غريباً

بروتوكول HTTP عديم الحالة Stateless، وهذا يعني أن كل طلب Request يصل إلى السيرفر مستقلاً تماماً عن الطلب الذي قبله، فالسيرفر يقرأ الطلب ويرد عليه ثم ينساه، ولا يوجد في البروتوكول نفسه أي شيء يقول «هذا الطلب جاء من نفس الشخص الذي سجل الدخول قبل دقيقة». وهذا التصميم مقصود، لأنه يجعل السيرفرات بسيطة وسهلة التوسع، فأي سيرفر يستطيع أن يرد على أي طلب، ولكن المشكلة أن المواقع التي نستخدمها ليست عديمة الحالة: هناك سلة مشتريات، ومستخدم مسجل الدخول، ولغة اختارها الزائر، وكل هذا يجب أن يبقى بين الصفحات. والصورة التالية تبين المشكلة:

مخطط يبين متصفحاً يرسل ثلاثة طلبات إلى السيرفر، الأول تسجيل دخول ينجح، والثاني والثالث يرد عليهما السيرفر بالرمز 401 لأنه لا يعرف من المرسل، وتحتها ثلاث أفكار بسيطة مرفوضة: اسم المستخدم في الرابط، وحقل مخفي في النموذج، وملف تعريف ارتباط يقول user=sara
السيرفر لا يربط الطلب الثاني بالأول، والأفكار البسيطة كلها تثق بقيمة يكتبها العميل

والحلول الأولى التي تخطر على بال أي مطور هي التالية، وكلها فاشلة لنفس السبب تقريباً:

  • اسم المستخدم في الرابط: بعد الدخول تضيف إلى كل رابط ?user=sara، والمشكلة أن أي زائر يستطيع أن يكتب ?user=admin بيده، والرابط يحفظ في سجل المتصفح وسجلات Logs السيرفر والـ Reverse Proxy، ويخرج إلى المواقع الأخرى في الترويسة Referer، وإذا نسيت إضافته إلى رابط واحد خرج المستخدم.
  • حقل مخفي Hidden Field في النماذج: يحمل اسم المستخدم من صفحة إلى صفحة داخل النماذج Forms، ولكنه لا ينتقل مع الروابط العادية، ويستطيع أي شخص أن يغيره من أدوات المطور DevTools في ثانية.
  • ملف تعريف ارتباط يقول من أنت: يضع السيرفر user=sara في ملف تعريف ارتباط، فيعيده المتصفح مع كل طلب، وهذا أقرب الحلول إلى الصحيح لأن المتصفح هو الذي يتذكر، ولكن القيمة نفسها يكتبها العميل، فإذا أرسل user=admin صدقه السيرفر، كما سوف نرى في التجربة العملية بأمر curl واحد.

وقد يتساءل البعض: ما المشكلة إذا كان المستخدم العادي لا يعرف كيف يغير ملف تعريف الارتباط؟ والإجابة أن المهاجم ليس مستخدماً عادياً، وأن الطلب في النهاية نص يكتبه أي برنامج، فالقاعدة التي يقوم عليها كل ما في هذا المقال هي: لا تقم بتصديق أي قيمة يرسلها العميل عن هويته، والسبب أن كل ما في الطلب يمكن تزويره. والحل الصحيح له شكلان: أن يعطي السيرفر المتصفح رقماً عشوائياً لا يعني شيئاً بنفسه، ويحفظ البيانات الحقيقية عنده، وهذه هي الجلسة Session، أو أن يعطيه بيانات موقعة Signed بمفتاح لا يملكه غير السيرفر، فلا يستطيع أحد أن يغيرها دون أن ينكشف، وهذا هو التوكن الموقع مثل JWT. وفي الحالتين يحتاج المتصفح إلى مكان يحفظ فيه هذه القيمة ويعيدها، وأشهر مكان هو ملف تعريف الارتباط.

ملفات تعريف الارتباط Cookies: ذاكرة صغيرة يحملها المتصفح

ملف تعريف الارتباط Cookie هو اسم وقيمة صغيرة يطلب السيرفر من المتصفح أن يحفظها، فيعيدها المتصفح تلقائياً مع كل طلب لاحق إلى نفس الموقع، كما يشرح دليل ملفات تعريف الارتباط في MDN. وقد أصبحت معياراً في RFC 6265 في أبريل 2011، وما زال هذا هو المعيار الرسمي، أما التحديث الذي يضيف SameSite والبادئات وغيرها، واسمه RFC 6265bis، فما زال مسودة Internet-Draft حتى كتابة هذا المقال (الإصدار 22 بتاريخ 1 ديسمبر 2025، وحالته «أرسل إلى IESG للنشر»)، مع أن المتصفحات تطبق أغلب ما فيه منذ سنوات.

والتشبيه الأقرب هو بطاقة المعطف في المطعم: تسلم معطفك عند الباب فيعطيك الموظف بطاقة عليها رقم، والبطاقة لا تحمل المعطف، ولا تعني شيئاً خارج هذا المطعم، ولكنك كلما قدمتها أعطوك معطفك أنت. والمتصفح يحمل لكل موقع مجموعة من هذه البطاقات، ويسمى المكان الذي يحفظها فيه وعاء ملفات تعريف الارتباط Cookie Jar.

الحوار كله يتم في ترويستين: السيرفر يرسل Set-Cookie في الرد Response، والمتصفح يعيد Cookie في كل طلب بعدها، وهذا ما رأيناه في التجربة العملية عندما أضفنا كتاباً إلى السلة دون تسجيل دخول:

curl -sk -i -c jar.txt -b jar.txt -d 'item=book' https://127.0.0.1:47810/cart/add

والمخرج سوف يكون كما يلي:

HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 11
etag: W/"b-5AiiWzjIFWAjH3x1+Vx0DCGExVQ"
set-cookie: __Host-sid=s%3A5IbRhRR2C_Yw9Si2JnpsrOyGoSQ2YPA_.0h0Pz2HI2cYFW9gcYXvIlRbvb1IROdW79uD0%2Fzhezh0; Path=/; Expires=Mon, 05 Oct 2026 22:24:55 GMT; HttpOnly; Secure; SameSite=Lax
date: Mon, 05 Oct 2026 21:54:55 GMT
connection: keep-alive
keep-alive: timeout=5

cart: book

ولاحظ أن الخيار -c jar.txt يجعل curl يحفظ ملفات تعريف الارتباط في ملف كما يفعل المتصفح، والخيار -b jar.txt يجعله يعيدها مع الطلب، أي أن curl هنا يتصرف كمتصفح صغير، وبعد ذلك يرسل مع كل طلب السطر التالي فقط:

Cookie: __Host-sid=s%3A5IbRhRR2C_Yw9Si2JnpsrOyGoSQ2YPA_.0h0Pz2HI2cYFW9gcYXvIlRbvb1IROdW79uD0%2Fzhezh0

والملاحظة المهمة هنا أن المتصفح لا يعيد الخصائص Attributes مثل Path وHttpOnly إلى السيرفر، وإنما يعيد الاسم والقيمة فقط، فالخصائص تعليمات من السيرفر إلى المتصفح يقرر بها المتصفح متى يرسل ملف تعريف الارتباط ولمن، والصورة التالية تبين الحوار كاملاً وتلخص الخصائص:

مخطط يبين المتصفح ووعاء ملفات تعريف الارتباط على اليسار والسيرفر على اليمين، في الخطوة الأولى يرسل المتصفح طلب الدخول، وفي الثانية يرد السيرفر بالترويسة Set-Cookie مع خصائصها فيدخل ملف تعريف الارتباط إلى الوعاء، وفي الثالثة يعيده المتصفح في الترويسة Cookie مع كل طلب، وتحتها بطاقة تشرح الخصائص Domain وPath وExpires وMax-Age وSecure وHttpOnly وSameSite والبادئة __Host- وPartitioned
يضع السيرفر ملف تعريف الارتباط مرة، ويعيده المتصفح مع كل طلب حسب الخصائص

الخصائص: أين يرسل، ومتى ينتهي، ومن يستطيع قراءته

هذه الخصائص هي الفرق بين ملف تعريف ارتباط آمن وآخر يسرق في أول ثغرة، وتفصيلها في توثيق Set-Cookie في MDN:

  • Domain: إذا لم تضعها يصبح ملف تعريف الارتباط خاصاً بالمضيف Host الذي أرسله فقط (Host-Only)، وإذا وضعت Domain=example.com يرسل أيضاً إلى كل النطاقات الفرعية Subdomains مثل blog.example.com، لذلك لا تضعها إلا إذا كنت تحتاج المشاركة فعلاً، والسبب أن أي نطاق فرعي مخترق يصبح قادراً على قراءة جلسة النطاق الرئيسي.
  • Path: المسار الذي يرسل معه، وPath=/ تعني كل الموقع، ولاحظ أن Path ليست حدوداً أمنية، لأن أي صفحة في نفس المصدر Origin تستطيع الوصول إلى ملفات المسارات الأخرى بطرق غير مباشرة.
  • Expires وMax-Age: متى ينتهي، الأولى تاريخ والثانية عدد ثوان، وإذا وضعتهما معاً فالأولوية لـ Max-Age، وإذا لم تضع أياً منهما فهو ملف جلسة Session Cookie يحذف عند إغلاق المتصفح، مع أن بعض المتصفحات التي تستعيد التبويبات بعد إعادة التشغيل تحتفظ به.
  • Secure: لا يرسل إلا عبر HTTPS، فلا يقرؤه من يتنصت على الشبكة، وإذا كان تطبيقك خلف Reverse Proxy ينهي TLS فقد يظن التطبيق أن الطلب جاء عبر HTTP فلا يضع هذه الخاصية أو لا يرسل ملف الجلسة أصلاً، وهذا ما شرحناه في دليل تطبيقك خلف Reverse Proxy.
  • HttpOnly: لا يستطيع JavaScript قراءته عبر document.cookie، ويبقى المتصفح يرسله مع الطلبات كالمعتاد، وهذه الخاصية تمنع أشهر طريقة لسرقة الجلسة، وهي سكربت محقون عبر ثغرة حقن السكربت Cross-Site Scripting واختصاراً XSS يقرأ ملف تعريف الارتباط ويرسله إلى المخترق.
  • SameSite: يحدد هل يرسل ملف تعريف الارتباط مع الطلبات التي تبدأ من موقع آخر، فالقيمة Strict لا ترسله أبداً من موقع آخر، حتى لو ضغط المستخدم رابطاً إلى موقعك من بريده فسوف يصل وكأنه غير مسجل، وLax ترسله عندما ينقل رابط في موقع آخر التبويب كله إلى موقعك بطلب GET، ولا ترسله مع نماذج POST أو الطلبات الخفية من موقع آخر، وNone ترسله دائماً وتشترط Secure، وهذه الخاصية أهم حماية من تزوير الطلب عبر المواقع Cross-Site Request Forgery واختصاراً CSRF كما سيأتي. ومنذ الإصدار 80 من Chrome في فبراير 2020 يعامل Chrome ملف تعريف الارتباط الذي لا يحدد SameSite كأنه Lax، ولكن ليست كل المتصفحات تفعل ذلك، لذلك حددها دائماً بنفسك.
  • البادئتان __Secure- و__Host-: إذا بدأ اسم ملف تعريف الارتباط بالبادئة __Secure- فلن يقبله المتصفح إلا مع Secure ومن صفحة HTTPS، وإذا بدأ بالبادئة __Host- فيشترط فوق ذلك Path=/ وعدم وجود Domain، أي أنه مقفل على هذا المضيف بالضبط، فلا يستطيع نطاق فرعي أن يكتب فوقه، وهي أفضل اختيار لملف الجلسة.
  • Partitioned: خاصية جديدة اسمها CHIPS، تجعل ملف الطرف الثالث محفوظاً في وعاء منفصل لكل موقع يظهر فيه، فإذا وضعت أداة دردشة في عدة مواقع، فلكل موقع ملف مختلف، ولا تستطيع الأداة أن تتبع الزائر بينها.

ويجدر الإشارة هنا إلى أن SameSite تتكلم عن «الموقع» Site وليس عن «المصدر» Origin، والموقع هو النطاق المسجل مع اللاحقة، فالعنوانان app.example.com وblog.example.com موقع واحد Same-Site مع أنهما مصدران مختلفان في نظر CORS، وهذا سبب آخر لاستخدام __Host-. وإذا كان تطبيقك جاهزاً ولا تستطيع تعديل كوده، فقد شرحنا كيف تضيف هذه الخصائص عبر الـ Reverse Proxy في قسم ملفات تعريف الارتباط من دليل ترويسات الأمان.

ملفات الطرف الأول والطرف الثالث، والتتبع

ملف الطرف الأول First-Party Cookie هو الذي يضعه الموقع الظاهر في شريط العنوان، أما ملف الطرف الثالث Third-Party Cookie فيضعه نطاق آخر محمل داخل الصفحة، مثل إعلان أو زر مشاركة أو أداة إحصاء من شركة أخرى. ولو حملت مئات المواقع سكربتاً من tracker.example، فإن هذا النطاق يرى ملفه نفسه في كل تلك المواقع، فيعرف أن الزائر الذي قرأ مقالاً في موقع أخبار هو نفسه الذي بحث عن حذاء في متجر، وهذا هو أساس التتبع الإعلاني عبر المواقع Cross-Site Tracking.

ولهذا بدأت المتصفحات تقيد هذه الملفات، ولكن كل متصفح بطريقته:

  • Safari: يمنع ملفات الطرف الثالث افتراضياً بالكامل منذ مارس 2020 (الإصدار 13.1).
  • Firefox: فعل الحماية الكاملة من ملفات تعريف الارتباط Total Cookie Protection لكل المستخدمين في يونيو 2022، فأصبح لكل موقع وعاء منفصل، ولا يرى الطرف الثالث في موقع ما ملفه في موقع آخر.
  • Chrome: أعلنت Google في يناير 2020 أنها سوف تلغي ملفات الطرف الثالث، وأجلت الموعد عدة مرات، ثم في يوليو 2024 تراجعت عن الإلغاء واقترحت بدلاً منه خياراً يحدده المستخدم، ثم في أبريل 2025 أعلنت أنها لن تضيف حتى هذا الخيار، وأن ملفات الطرف الثالث تبقى مسموحة افتراضياً ويتحكم فيها المستخدم من إعدادات الخصوصية، مع منعها افتراضياً في وضع التصفح المتخفي Incognito. وفي أكتوبر 2025 أوقفت أغلب تقنيات Privacy Sandbox التي كانت سوف تحل محلها، وأبقت CHIPS وFedCM.

وما يهمك كمطور أن أي تصميم يعتمد على ملف طرف ثالث، مثل تطبيق داخل iframe من نطاق آخر يحتاج أن يبقى المستخدم مسجلاً فيه، سوف ينكسر في Safari وFirefox اليوم، لذلك اجعل الواجهة وال API والدخول تحت نطاق واحد كلما استطعت. ويجدر الإشارة أيضاً إلى أن قوانين حماية البيانات في بعض الدول تشترط موافقة الزائر قبل ملفات التتبع، وهذا موضوع قانوني خارج نطاق هذا المقال.

الحجم والعدد

ملف تعريف الارتباط صغير بطبيعته، فالمعيار RFC 6265 يطلب من المتصفح أن يقبل على الأقل 4096 بايت لكل ملف (الاسم والقيمة والخصائص معاً)، و50 ملفاً لكل نطاق، و3000 ملف في المجموع، والمسودة الجديدة تحدد الاسم والقيمة معاً بـ 4096 بايت وقيمة كل خاصية بـ 1024 بايت. وأهم من الحد نفسه أن كل ملفات النطاق ترسل مع كل طلب، حتى طلبات الصور وملفات CSS، فإذا وضعت فيها 3 كيلوبايت فأنت تضيف 3 كيلوبايت إلى كل طلب، لذلك ضع في ملف تعريف الارتباط معرفاً صغيراً وليس البيانات نفسها.

الجلسة على السيرفر Server-Side Session

الآن نجمع الأفكار السابقة: السيرفر يولد معرف جلسة Session ID عشوائياً طويلاً، ويضعه في ملف تعريف ارتباط بالخصائص الصحيحة، ويحفظ عنده تحت هذا المعرف كل ما يريد أن يتذكره: من هو المستخدم، ومتى دخل، وما في سلته. والمعرف نفسه لا يحمل أي معلومة، فهو مثل بطاقة المعطف، وتخمينه مستحيل عملياً، لأن دليل OWASP لإدارة الجلسات يطلب 64 بت على الأقل من العشوائية Entropy، والمكتبة التي استخدمناها في التجربة express-session تولد 24 بايت أي 192 بت.

ولاحظ في المخرج السابق أن القيمة تبدأ بـ s%3A وهي s: بعد الترميز، ثم المعرف، ثم نقطة، ثم توقيع HMAC للمعرف بسر السيرفر، وهذا التوقيع ليس ضرورياً لأمان الجلسة لأن المعرف عشوائي أصلاً، ولكنه يجعل السيرفر يرفض أي قيمة لم يصدرها هو قبل أن يبحث عنها في المخزن.

دورة حياة الجلسة: من الدخول إلى الخروج

  1. الإنشاء: تبدأ الجلسة عندما يحتاج التطبيق أن يتذكر شيئاً، مثل إضافة منتج إلى السلة قبل تسجيل الدخول، أو عند الدخول نفسه.
  2. تغيير المعرف بعد الدخول Session ID Regeneration: لحظة نجاح الدخول ينشئ التطبيق معرفاً جديداً وينقل إليه البيانات ويحذف القديم، والسبب هجوم تثبيت الجلسة Session Fixation: إذا استطاع المخترق أن يضع في متصفح الضحية معرف جلسة يعرفه هو، عبر رابط أو نطاق فرعي مخترق، ثم سجل الضحية الدخول بنفس المعرف، فإن المخترق يصبح مسجلاً باسمه. ولذلك ينص دليل OWASP على تغيير المعرف بعد أي تغيير في مستوى الصلاحية، أي عند الدخول، وعند تفعيل صلاحيات المدير، وعند تغيير كلمة المرور.
  3. انتهاء الخمول Idle Timeout: إذا لم يستخدم المستخدم الجلسة مدة معينة تنتهي، ويقترح دليل OWASP من 2 إلى 5 دقائق للتطبيقات عالية القيمة، ومن 15 إلى 30 دقيقة للتطبيقات منخفضة الخطورة.
  4. الانتهاء المطلق Absolute Timeout: حتى لو بقي المستخدم نشطاً، تنتهي الجلسة بعد مدة ثابتة من الدخول، مثل 4 إلى 8 ساعات لموظف يعمل يوماً كاملاً، والسبب أن الجلسة المسروقة لا يجب أن تعيش للأبد لمجرد أن المخترق يستخدمها باستمرار.
  5. الخروج Logout: يحذف التطبيق الجلسة من المخزن، وليس من المتصفح فقط، ثم يرسل ملف تعريف ارتباط منتهياً حتى يحذفه المتصفح، والجزء الأول هو المهم، لأن من نسخ القيمة قبل الخروج لن تنفعه بعد حذفها من السيرفر.

عندما يعمل التطبيق على أكثر من نسخة

الطريقة الأسهل أن يحفظ كل تطبيق الجلسات في ذاكرته، وهذا يعمل ما دامت هناك نسخة واحدة، ولكن عندما تضع نسختين خلف موزع حمل Load Balancer تظهر المشكلة: المستخدم يسجل الدخول على النسخة الأولى، والطلب التالي يصل إلى النسخة الثانية التي لا تعرفه، فيظهر وكأنه خرج، وإذا أعيد تشغيل التطبيق خرج كل المستخدمين. والحلان المعروفان:

  • الجلسات اللاصقة Sticky Sessions: يرسل الـ Load Balancer كل زائر إلى نفس النسخة دائماً، وهذا حل سريع ولكنه هش، لأن سقوط النسخة أو إعادة تشغيلها يخرج كل من عليها، وتوزيع الحمل يصبح غير متساو.
  • مخزن جلسات مشترك Shared Session Store: كل النسخ تقرأ وتكتب الجلسات في مكان واحد، وأشهر اختيار هو Redis أو Valkey، وهو مشروع مفتوح المصدر برخصة BSD تدعمه Linux Foundation، مبني على كود Redis ويتكلم البروتوكول نفسه، لذلك تعمل معه مكتبات Redis دون تغيير. وميزة هذه المخازن أن كل مفتاح له مدة انتهاء TTL، فتحذف الجلسات المنتهية وحدها، ويمكن أيضاً استخدام قاعدة البيانات نفسها مثل PostgreSQL إذا كان عدد الطلبات معقولاً.
مخطط من جزأين، في الأيمن متصفح يرسل ملف الجلسة إلى Proxy يوزع الطلبات على نسختين من التطبيق، النسخة الأولى تحفظ الجلسة في ذاكرتها فترد بأنك سارة، والثانية ذاكرتها فارغة فترد بالرمز 401، وفي الأيسر نفس النسختين تقرآن الجلسة من مخزن مشترك Valkey أو Redis فيه المفتاح sess:7f3a مع المستخدم والسلة، فترد أي نسخة بشكل صحيح
الجلسة في ذاكرة كل نسخة تضيع عند النسخة الأخرى، والمخزن المشترك يجعل أي نسخة قادرة على الرد

ولاحظ أن المخزن الافتراضي في express-session اسمه MemoryStore، وتوثيق المكتبة نفسه يقول إنه غير مصمم للإنتاج Production، وإنه يسرب الذاكرة ولا يعمل مع أكثر من عملية Process، وهذا ينطبق على أغلب الأطر Frameworks، فالمخزن الافتراضي غالباً للتطوير فقط.

ماذا تحفظ في الجلسة، وماذا لا تحفظ

احفظ في الجلسة ما يحتاجه التطبيق في كل طلب ويتغير مع المستخدم: معرف المستخدم، ووقت الدخول، وطريقة الدخول (كلمة مرور أو Passkey أو SSO)، والسلة، والتوكنات التي حصل عليها التطبيق من مزود الهوية إذا كان يستخدم OAuth. ولا تقم بحفظ نسخة كاملة من صلاحيات المستخدم إذا كانت تتغير كثيراً، والسبب أن المدير إذا سحب صلاحية من موظف فالجلسة سوف تبقى تحمل الصلاحية القديمة حتى تنتهي، فالأفضل أن تحفظ معرف المستخدم وتقرأ صلاحياته من قاعدة البيانات أو من ذاكرة مؤقتة Cache قصيرة. ولا تحفظ كلمة المرور بأي شكل، ولا أي بيانات لا تحتاجها، لأن مخزن الجلسات يصبح هدفاً، فمن يقرؤه يستطيع أن ينسخ أي جلسة فيه، لذلك احمه بكلمة مرور ولا تعرضه على الشبكة.

التوكنات وJWT: عندما لا يريد السيرفر أن يتذكر

الجلسة تعني أن السيرفر يبحث في المخزن مع كل طلب، وهذا لا يسبب مشكلة لتطبيق ويب واحد، ولكن تخيل منظومة فيها عشر خدمات Microservices، وتطبيق جوال، وواجهات API يستدعيها شركاء من خارج المؤسسة: هل تتصل كل خدمة بنفس مخزن الجلسات؟ وهل يحمل تطبيق الجوال ملفات تعريف الارتباط؟ ومن هنا جاءت فكرة التوكن عديم الحالة Stateless Token: بدلاً من رقم يبحث عنه السيرفر، يعطي السيرفر العميل ورقة تحمل المعلومات نفسها، مختومة بختم لا يستطيع أحد أن يقلده، فأي خدمة تملك مفتاح التحقق تقرأ الورقة وتتحقق من الختم وتعرف من المستخدم دون أن تسأل أحداً.

والصيغة الأشهر لهذه الورقة هي JSON Web Token واختصاراً JWT (تنطق «جوت»)، المعرفة في RFC 7519 في مايو 2015، ويرسلها العميل عادة في الترويسة Authorization: Bearer … وليس في ملف تعريف ارتباط، وإن كان من الممكن أن توضع فيه أيضاً.

مم يتكون JWT؟

يتكون التوكن من ثلاثة أجزاء بينها نقطتان header.payload.signature، كل جزء مرمز بترميز Base64url: الترويسة Header وفيها خوارزمية التوقيع، والمحتوى Payload وفيه الحقول Claims، ثم التوقيع Signature المحسوب على الجزأين الأولين، وقد فككنا توكناً حقيقياً جزءاً جزءاً في قسم كيف تقرأ JWT من مقال OAuth، فلن نكرره هنا. والصورة التالية تبين كيف يستخدم التوكن من إصداره إلى التحقق منه:

مخطط يبين عميلاً يسجل الدخول عند خدمة الإصدار فيحصل على JWT من ثلاثة أجزاء، ثم يرسله إلى ال API في الترويسة Authorization، فيتحقق ال API من التوقيع والخوارزمية ووقت الانتهاء والمصدر والجمهور دون أي استعلام في قاعدة البيانات، ثم يرد، وتحته الأجزاء الثلاثة للتوكن والفرق بين HS256 بسر مشترك وRS256 أو ES256 بمفتاح خاص ومفتاح عام من JWKS
السيرفر يوقع مرة، وال API يتحقق بالمفتاح دون أن يسأل أحداً

وأهم ما تتذكره أن Base64url ترميز Encoding وليس تشفيراً Encryption، فأي أحد يحمل التوكن يقرأ محتواه بأمر واحد كما سوف نرى في التجربة، والتوقيع لا يخفي شيئاً، وإنما يكشف أي تعديل. وهذا النوع الموقع اسمه JWS، ومعرف في RFC 7515، وهو الذي تقصده كل المكتبات عندما تقول JWT. أما إذا كنت تحتاج أن يكون المحتوى سرياً فهناك صيغة مشفرة اسمها JWE في RFC 7516، وتتكون من خمسة أجزاء، واستخدامها أقل بكثير، والأبسط غالباً ألا تضع في التوكن ما يحتاج إلى إخفاء.

HS256 أم RS256 أم ES256؟

  • HS256: توقيع HMAC مع SHA-256 بسر مشترك Shared Secret واحد، يعرفه من يصدر التوكن ومن يتحقق منه، وهو بسيط وسريع ومناسب عندما يكون المصدر والمتحقق نفس التطبيق، كما في تجربتنا. والعيب أن كل من يستطيع التحقق يستطيع التوقيع أيضاً، فإذا عرفت عشر خدمات السر فأي واحدة منها تستطيع أن تصدر توكن باسم أي مستخدم. ويشترط RFC 7518 أن يكون المفتاح بطول ناتج الهاش على الأقل، أي 256 بت لـ HS256، وهذا ما يولده الأمر openssl rand -base64 32.
  • RS256 وES256: توقيع بزوج مفاتيح، RSA في الأولى ومنحنيات بيضاوية Elliptic Curves في الثانية، فالمفتاح الخاص Private Key لا يغادر خدمة الإصدار، والخدمات الأخرى تملك المفتاح العام Public Key فقط، فتتحقق ولا تستطيع أن توقع. وتنشر خدمة الإصدار مفاتيحها العامة في عنوان بصيغة JWKS المعرفة في RFC 7517، فتجلبها الخدمات وتختار المفتاح حسب الحقل kid في الترويسة، وهذا يجعل تغيير المفاتيح دورياً Key Rotation ممكناً دون توقف، وقد شرحنا ذلك في قسم التحقق من التوقيع عبر JWKS. وES256 يعطي توقيعاً أقصر من RS256 بنفس مستوى الأمان.

والقاعدة العملية: إذا كان من يصدر التوكن ومن يتحقق منه تطبيقاً واحداً فـ HS256 مع سر قوي يكفي، وإذا كانت هناك أكثر من خدمة تتحقق، أو جهة خارجية، فاستخدم RS256 أو ES256.

الحقول القياسية Claims

الحقلمعناهما الذي يفعله من يتحقق
issالمصدر Issuer: من أصدر التوكنيقارنه بالمصدر الذي يثق به حرفاً بحرف
subالموضوع Subject: معرف المستخدم أو الخدمةيربط به الطلب بالحساب
audالجمهور Audience: لمن صدريرفض التوكن إذا لم يكن اسمه فيه، حتى لا يقبل توكناً صدر لخدمة أخرى
expوقت انتهاء الصلاحية Expirationيرفضه بعده، مع فارق صغير لاختلاف الساعات Clock Skew
nbfلا يقبل قبل Not Beforeيرفضه قبل هذا الوقت
iatوقت الإصدار Issued Atيعرف عمر التوكن، ويرفض ما صدر قبل حدث معين مثل تغيير كلمة المرور
jtiمعرف فريد للتوكن JWT IDيستخدم في قائمة الإلغاء، أو لمنع استخدام التوكن مرتين

والأوقات كلها بالثواني منذ 1 يناير 1970 بتوقيت UTC، أي 1791237635 وليس تاريخاً مكتوباً.

المشكلة الأساسية: كيف تلغي توكن قبل أن ينتهي؟

هنا نصل إلى الثمن الذي تدفعه مقابل عدم البحث في المخزن، فالسيرفر لا يحفظ شيئاً عن التوكن بعد إصداره، وأي توكن توقيعه صحيح ولم يصل إلى exp سوف يقبل. فإذا ضغط المستخدم «تسجيل الخروج» فكل ما يحدث أن التطبيق يحذف التوكن من جهازه، ولكن إذا كان أحد قد نسخه قبل ذلك فسوف يبقى يعمل حتى ينتهي وقته، وكذلك إذا عطل المدير الحساب أو سحب منه صلاحية المدير، فالتوكن الذي في يده ما زال يقول role: admin. والصورة التالية تبين الفرق:

مخطط بخطين زمنيين، في الأول جلسة على السيرفر تبدأ عند الدخول وتحذف من المخزن عند الخروج بعد دقيقة، فيرفض السيرفر النسخة المسروقة من ملف الجلسة فوراً، وفي الثاني JWT صلاحيته خمس دقائق، يحذفه العميل عند الخروج ولكن النسخة المسروقة يقبلها ال API حتى وقت الانتهاء، وتحتهما طرق تقصير هذه النافذة
الجلسة تنتهي لحظة حذفها، وال JWT يبقى صالحاً حتى exp مهما حدث على السيرفر

والحلول المعروفة كلها تقلل المشكلة ولا تلغيها:

  • عمر قصير مع توكن تجديد: يكون الـ Access Token من 5 إلى 15 دقيقة، ومعه Refresh Token أطول عمراً يحفظ في مكان أكثر أماناً ويستبدل بتوكن جديد مع كل استخدام Rotation، وعند الخروج يلغى توكن التجديد في السيرفر، فتصبح النافذة دقائق بدلاً من أيام، وقد شرحنا تدفق Refresh Token ومدة صلاحية التوكنات وتدويرها في مقال OAuth.
  • قائمة إلغاء Deny-List: يحفظ السيرفر معرف jti للتوكنات الملغاة حتى وقت انتهائها، ويفحصها مع كل طلب، وهذا يعمل كما سوف نرى في التجربة، ولكن لاحظ أنك عدت إلى البحث في مخزن مع كل طلب، أي إلى الشيء الذي جاء JWT ليتجنبه، وإن كانت القائمة صغيرة لأنها تحتوي الملغى فقط. ودليل OWASP لـ JWT يوصي ببناء القائمة على jti وiss وليس على التوكن كاملاً أو هاشه.
  • تغيير مفتاح التوقيع: يبطل كل التوكنات مرة واحدة، وهو حل للطوارئ فقط، مثل تسرب المفتاح، لأنه يخرج كل المستخدمين.

أخطاء تكررت مع JWT

  • الثقة بالحقل alg في الترويسة: الترويسة يكتبها من صنع التوكن، فإذا كانت المكتبة تختار طريقة التحقق حسب alg فقد يرسل المخترق "alg":"none" أي توكناً دون توقيع، أو يرسل HS256 لسيرفر يتوقع RS256 فتستخدم المكتبة المفتاح العام المنشور كأنه سر HMAC، وهذا ما يسمى خلط الخوارزميات Algorithm Confusion. والحل أن يحدد التطبيق الخوارزميات المسموحة بنفسه، كما ينص RFC 8725 لأفضل الممارسات الحالية مع JWT الصادر في فبراير 2020.
  • سر ضعيف مع HS256: من يحمل توكناً واحداً يستطيع أن يجرب ملايين الأسرار على جهازه دون أن يتصل بالسيرفر، فإذا كان السر كلمة مثل secret أو اسم الشركة فسوف يجده ويصدر ما يشاء من توكنات، لذلك لا تقم باستخدام كلمة يحفظها إنسان، وولد السر عشوائياً.
  • بيانات حساسة في المحتوى: المحتوى مقروء لكل من يحمل التوكن، وهو يمر في سجلات السيرفرات وأدوات المراقبة، فلا تضع فيه رقم هوية أو هاتفاً أو أي سر.
  • عدم التحقق من aud وiss وexp: التحقق من التوقيع وحده يقول إن التوكن صحيح، ولا يقول إنه صدر لك أو إنه ما زال صالحاً، فإذا كانت عندك عدة خدمات تثق بنفس المصدر فإن توكن الخدمة الأولى يفتح الثانية إذا لم تتحقق من aud.
  • توكن طويل العمر دون تجديد: توكن صالح لشهر لأن «التجديد معقد» هو كلمة مرور ثانية لا يمكن إلغاؤها.
📌
في 31 مارس 2015 نشر الباحث Tim McLean في مدونة Auth0 أنه وجد مكتبات JWT كثيرة تسمح بتجاوز التحقق من التوقيع، منها node-jsonwebtoken وpyjwt وphp-jwt، لأنها كانت تختار خوارزمية التحقق من الحقل alg الذي يكتبه المهاجم، فبعضها قبل توكنات none دون توقيع، وبعضها قبل توكن HS256 موقعاً بالمفتاح العام لسيرفر يستخدم RS256. وسجلت ثغرة المكتبة jsonwebtoken لاحقاً برقم CVE-2015-9235 بدرجة حرجة، وأغلقت في الإصدار 4.2.2. والدرس أن المكتبة يجب أن تعرف الخوارزمية من إعداداتك أنت وليس من التوكن، لذلك تجد في كود التجربة algorithms: ['HS256'].

الجلسات أم JWT؟ مقارنة صادقة

يقدم كثير من المقالات JWT كأنه البديل الحديث للجلسات، والحقيقة أن كل واحد منهما يحل مشكلة مختلفة، والجدول التالي يبين الفرق:

المعيارالجلسة على السيرفرJWT عديم الحالة
الإلغاء Revocation وتسجيل الخروجفوري، بحذف الجلسة من المخزنيبقى صالحاً حتى exp، إلا مع قائمة إلغاء
تغيير الصلاحياتيظهر في الطلب التالي إذا قرأت الصلاحيات من قاعدة البياناتيظهر عند إصدار توكن جديد
التوسع Scalingيحتاج مخزناً مشتركاً مثل Valkeyأي خدمة تملك المفتاح تتحقق وحدها
الحجم في كل طلبمعرف صغير، أقل من 100 بايتمئات البايتات، ويكبر مع كل حقل تضيفه
تطبيقات الجوال وال APIs والخدماتممكن ولكن غير طبيعيمناسب، في الترويسة Authorization
الإرسال من المتصفحتلقائي في ملف تعريف الارتباط، فيحتاج حماية من CSRFيرسله الكود، فيحتاج مكاناً يحفظه فيه بعيداً عن XSS
التعقيدكل الأطر تدعمه جاهزاًمفاتيح، وتجديد، وتحقق من الحقول، وقائمة إلغاء

والتوصية التي نخرج بها:

  • تطبيق ويب يعمل في المتصفح، والسيرفر يرسم الصفحات أو يخدم الواجهة من نفس النطاق: جلسة على السيرفر في ملف تعريف ارتباط بالخصائص HttpOnly وSecure وSameSite=Lax والبادئة __Host-، وهذا هو الحل الأبسط والأكثر أماناً، وهو ما تفعله أغلب التطبيقات التي تستضيفها مثل Gitea وGrafana وNextcloud.
  • APIs تستدعيها تطبيقات الجوال أو خدمات أخرى: توكنات قصيرة العمر في الترويسة Authorization، وغالباً تصدرها خدمة دخول مركزية عبر OAuth.
  • تطبيق صفحة واحدة SPA يحتاج توكنات OAuth: نمط الخلفية للواجهة Backend for Frontend واختصاراً BFF، حيث يحفظ سيرفر صغير التوكنات عنده، ولا يعطي المتصفح إلا ملف جلسة HttpOnly، كما شرحنا في قسم أين تحفظ التوكنات.

وقد يتساءل البعض: أليس JWT أسرع لأنه لا يبحث في قاعدة البيانات؟ والإجابة أن البحث عن مفتاح في Valkey يستغرق أجزاء من الألف من الثانية، وأغلب الطلبات تقرأ من قاعدة البيانات أصلاً لتعرض البيانات، فإذا لم يكن عندك عشرات الخدمات أو ملايين الطلبات فإن هذا الفرق لا يستحق أن تتنازل من أجله عن الإلغاء الفوري.

أين تحفظ ما يثبت الدخول في المتصفح؟

إذا وصل التوكن إلى المتصفح فهناك ثلاثة أماكن ممكنة، ولكل منها خطر مختلف، والخطران اللذان تقارن بهما هما: حقن السكربت Cross-Site Scripting واختصاراً XSS، حيث ينجح المخترق في تشغيل JavaScript داخل صفحتك، وتزوير الطلب عبر المواقع Cross-Site Request Forgery واختصاراً CSRF، حيث يجعل موقع آخر متصفح الضحية يرسل إلى موقعك طلباً يحمل ملف الجلسة تلقائياً.

مخطط من ثلاثة أعمدة، الأول ملف تعريف ارتباط HttpOnly لا يقرؤه السكربت المحقون ولكنه يرسل تلقائياً فيحتاج حماية من CSRF بالخاصية SameSite وتوكن CSRF وفحص Origin، والثاني متغير في ذاكرة JavaScript يضيع عند تحديث الصفحة ويستطيع السكربت المحقون أن يستخدمه ما دامت الصفحة مفتوحة، والثالث localStorage وsessionStorage يقرؤه أي سكربت فيرسل التوكن إلى سيرفر المخترق
لا يوجد مكان آمن من XSS، وإنما مكان يقلل ما يأخذه المخترق
  • localStorage وsessionStorage: أسهل مكان، وأي سكربت في الصفحة يقرؤه، فثغرة XSS واحدة، أو مكتبة JavaScript مخترقة تحملها الصفحة، تعني أن التوكن خرج إلى جهاز المخترق ويعمل من هناك حتى ينتهي، وlocalStorage لا ينتهي وحده، لذلك لا تقم بحفظ توكن طويل العمر فيه، وقد نصح دليل OWASP لأمان HTML5 بعدم حفظ معرفات الجلسة فيه.
  • متغير في الذاكرة: يضيع عند تحديث الصفحة، والسكربت المحقون لا يستطيع غالباً أن يقرأه إذا كان مخفياً داخل الكود، ولكنه يستطيع أن يرسل الطلبات باسم المستخدم ما دامت الصفحة مفتوحة، ويحتاج التطبيق طريقة ليحصل على توكن جديد بعد التحديث، وغالباً تكون ملف تعريف ارتباط.
  • ملف تعريف ارتباط HttpOnly: لا يقرؤه أي سكربت، فلا يخرج من المتصفح حتى مع XSS، وإن كان السكربت المحقون يستطيع أن يرسل الطلبات من داخل الصفحة ما دامت مفتوحة. وثمن ذلك أن المتصفح يرسله تلقائياً، فيجب أن تحمي التطبيق من CSRF.

والحماية من CSRF ثلاث طبقات، وتفصيلها في دليل OWASP لمنع CSRF:

  1. SameSite=Lax أو Strict: لا يرسل المتصفح ملف الجلسة مع نموذج POST أو طلب خفي يبدأ من موقع آخر، وهذه أهم طبقة وأرخصها.
  2. توكن CSRF: قيمة عشوائية يضعها السيرفر في الصفحة ويطلبها مع كل طلب يغير البيانات، والموقع الآخر لا يستطيع قراءتها، وأغلب الأطر تضيفه جاهزاً، فلا تقم بتعطيله.
  3. فحص Origin: يرسل المتصفح مع الطلبات التي تغير البيانات الترويسة Origin، فيرفض السيرفر أي طلب من مصدر لا يعرفه. وتذكر أن CORS لا تمنع CSRF، لأن الطلب يصل وينفذ حتى لو منع المتصفح الصفحة من قراءة الرد، وقد شرحنا الفرق بين هذه الآليات في مقارنة CORS وCSP وCSRF وSameSite.

أما XSS فلا يحميك منه مكان التخزين، وإنما ترميز المخرجات Output Encoding في القوالب، وسياسة أمان المحتوى Content-Security-Policy التي شرحناها في دليل ترويسات الأمان، والسبب أن المخترق الذي يشغل كوده في صفحتك يستطيع أن يفعل أي شيء يفعله المستخدم، فالتخزين الصحيح يمنعه من أخذ الجلسة معه فقط.

تجربة عملية: نبني الطرق الثلاث ثم نكسرها

في هذه التجربة نكتب تطبيق Node.js صغيراً فيه الطرق الثلاث: تسجيل دخول بسيط يثق بملف تعريف الارتباط، وجلسة على السيرفر مكتوبة بشكل صحيح، وAPI يستخدم JWT، ونضع أمامه Proxy صغيراً ينهي HTTPS ويوزع الطلبات، حتى نستطيع لاحقاً تشغيل نسختين من التطبيق، ثم نستخدم Valkey كمخزن مشترك. ونستخدم curl بدلاً من المتصفح حتى نرى الترويسات كما هي. وكل شيء يعمل على 127.0.0.1 فقط، والمطلوب Node.js 22 وDocker وOpenSSL وcurl.

تجهيز المشروع

ننشئ المجلد ونثبت المكتبات، وهي express للتطبيق، وexpress-session للجلسات، وconnect-redis وredis للمخزن المشترك، وjsonwebtoken للتوكنات:

mkdir lab-session && cd lab-session
npm init -y
npm pkg set type=module
npm install [email protected] [email protected] [email protected] [email protected] [email protected]

ثم نولد شهادة موقعة ذاتياً Self-Signed Certificate للعنوان 127.0.0.1، لأن ملف الجلسة سوف يحمل الخاصية Secure والبادئة __Host-، فلا يقبله أي عميل إلا عبر HTTPS:

openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 7 \
  -subj "/CN=127.0.0.1" -addext "subjectAltName=IP:127.0.0.1" \
  -keyout key.pem -out cert.pem

والملف التالي هو الـ Proxy، وهو يقوم بما يقوم به Nginx أو Caddy أمام تطبيقك: ينهي HTTPS، ويرسل كل طلب إلى النسخة التالية من التطبيق بالتناوب Round Robin، ويضيف الترويستين X-Forwarded-Proto وX-Forwarded-For:

// proxy.js: terminates HTTPS and sends each request to the next app in turn (round robin)
import https from 'node:https';
import http from 'node:http';
import { readFileSync } from 'node:fs';

const BACKENDS = (process.env.BACKENDS || '127.0.0.1:47811').split(',');
let next = 0;

https.createServer({ key: readFileSync('key.pem'), cert: readFileSync('cert.pem') }, (req, res) => {
    const [host, port] = BACKENDS[next++ % BACKENDS.length].split(':');
    const headers = { ...req.headers, 'x-forwarded-proto': 'https', 'x-forwarded-for': req.socket.remoteAddress };
    const up = http.request({ host, port, path: req.url, method: req.method, headers }, (r) => {
        res.writeHead(r.statusCode, r.headers);
        r.pipe(res);
    });
    up.on('error', () => { res.writeHead(502); res.end('bad gateway\n'); });
    req.pipe(up);
}).listen(47810, '127.0.0.1', () => console.log(`proxy on https://127.0.0.1:47810 -> ${BACKENDS.join(', ')}`));

والملف التالي هو التطبيق كاملاً، وسوف نمر على كل جزء منه في الخطوات التالية:

// app.js: one small app that remembers the user in three ways
import express from 'express';
import session from 'express-session';
import { RedisStore } from 'connect-redis';
import { createClient } from 'redis';
import jwt from 'jsonwebtoken';
import { randomUUID } from 'node:crypto';

const NAME = process.env.NAME || 'app-a';
const PORT = Number(process.env.PORT || 47811);
const USERS = { sara: 'correct-horse-battery' };      // test user only
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');                   // the proxy on 127.0.0.1 terminates HTTPS
app.use(express.urlencoded({ extended: false }));
const log = (msg) => console.log(`[${NAME}] ${msg}`);

// ---------- 1) the naive way: trust a cookie that says who you are ----------
const readCookie = (req, name) => (req.headers.cookie || '').split('; ').find(c => c.startsWith(name + '='))?.split('=')[1];
app.post('/naive/login', (req, res) => {
    const { username, password } = req.body;
    if (USERS[username] !== password) return res.status(401).send('wrong password\n');
    res.cookie('user', username);
    res.send(`welcome ${username}\n`);
});
app.get('/naive/me', (req, res) => {
    const user = readCookie(req, 'user');
    if (!user) return res.status(401).send('not logged in\n');
    res.send(`naive: you are ${user}\n`);
});

// ---------- 2) server-side sessions ----------
let redis = null, store;                                // no REDIS_URL: sessions live in this process's memory
if (process.env.REDIS_URL) {
    redis = createClient({ url: process.env.REDIS_URL });
    await redis.connect();
    store = new RedisStore({ client: redis, prefix: 'sess:' });
}
app.use(session({
    name: '__Host-sid',
    secret: process.env.SESSION_SECRET,
    store,
    resave: false,
    saveUninitialized: false,                           // no cookie until there is something to remember
    rolling: true,                                      // every request pushes the idle timeout forward
    cookie: { httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 30 * 60 * 1000 },
}));

app.post('/cart/add', (req, res) => {
    req.session.cart = [...(req.session.cart || []), req.body.item];
    log(`cart for session ${req.session.id.slice(0, 8)}…: ${req.session.cart.join(', ')}`);
    res.send(`cart: ${req.session.cart.join(', ')}\n`);
});

app.post('/login', (req, res, next) => {
    const { username, password } = req.body;
    if (USERS[username] !== password) return res.status(401).send('wrong password\n');
    const oldId = req.session.id, cart = req.session.cart;
    req.session.regenerate((err) => {                   // new ID after login: stops session fixation
        if (err) return next(err);
        req.session.user = username;
        req.session.cart = cart;
        req.session.loginAt = Date.now();
        req.session.save((err) => {
            if (err) return next(err);
            log(`login ${username}: session ${oldId.slice(0, 8)}… -> ${req.session.id.slice(0, 8)}…`);
            res.send(`welcome ${username}\n`);
        });
    });
});

const ABSOLUTE = 8 * 60 * 60 * 1000;                    // 8 hours, whatever the activity
app.get('/me', (req, res) => {
    if (!req.session.user) return res.status(401).send(`${NAME}: not logged in\n`);
    if (Date.now() - req.session.loginAt > ABSOLUTE) return req.session.destroy(() => res.status(401).send('session expired\n'));
    res.send(`${NAME}: you are ${req.session.user}, cart: ${(req.session.cart || []).join(', ') || 'empty'}\n`);
});

app.post('/logout', (req, res) => {
    const id = req.session.id;
    req.session.destroy(() => {
        log(`logout: session ${id.slice(0, 8)}… destroyed`);
        res.clearCookie('__Host-sid', { path: '/', secure: true, httpOnly: true, sameSite: 'lax' });
        res.send('bye\n');
    });
});

// ---------- 3) JWT for an API ----------
const JWT_SECRET = process.env.JWT_SECRET;
const JWT_TTL = Number(process.env.JWT_TTL || 300);   // seconds
const VERIFY = { algorithms: ['HS256'], issuer: 'https://auth.example.com', audience: 'lab-api' };
const denied = new Set();                               // used only without Valkey

app.post('/api/token', (req, res) => {
    const { username, password } = req.body;
    if (USERS[username] !== password) return res.status(401).json({ error: 'wrong password' });
    const token = jwt.sign({ sub: username, role: 'user' }, JWT_SECRET,
        { algorithm: 'HS256', expiresIn: JWT_TTL, issuer: VERIFY.issuer, audience: VERIFY.audience, jwtid: randomUUID() });
    res.json({ token });
});

const isDenied = async (jti) => redis ? (await redis.exists(`deny:${jti}`)) === 1 : denied.has(jti);
app.get('/api/me', async (req, res) => {
    const token = (req.headers.authorization || '').replace(/^Bearer /, '');
    try {
        const claims = jwt.verify(token, JWT_SECRET, VERIFY);
        if (await isDenied(claims.jti)) return res.status(401).json({ error: 'token revoked' });
        res.json({ served_by: NAME, sub: claims.sub, role: claims.role, expires_in: claims.exp - Math.floor(Date.now() / 1000) });
    } catch (e) {
        log(`rejected token: ${e.name}: ${e.message}`);
        res.status(401).json({ error: `${e.name}: ${e.message}` });
    }
});

app.post('/api/revoke', async (req, res) => {
    const token = (req.headers.authorization || '').replace(/^Bearer /, '');
    try {
        const { jti, exp } = jwt.verify(token, JWT_SECRET, VERIFY);
        const ttl = exp - Math.floor(Date.now() / 1000);
        if (redis) await redis.set(`deny:${jti}`, '1', { EX: ttl }); else denied.add(jti);
        log(`revoked jti ${jti.slice(0, 8)}… for ${ttl}s`);
        res.json({ revoked: jti });
    } catch (e) { res.status(401).json({ error: e.message }); }
});

app.listen(PORT, '127.0.0.1', () => log(`listening on 127.0.0.1:${PORT}, sessions in ${redis ? 'Valkey' : 'memory'}`));

في الكود أعلاه لاحظ التالي:

  • app.set('trust proxy', 'loopback') يجعل التطبيق يصدق X-Forwarded-Proto فقط إذا جاء الطلب من 127.0.0.1، وبدونه يظن التطبيق أن الطلب جاء عبر HTTP فلا يرسل ملف الجلسة لأنه يحمل Secure، وهذا هو الخطأ الذي شرحناه في إعداد Express خلف ال Proxy.
  • saveUninitialized: false يعني أن الزائر لا يحصل على ملف جلسة حتى يكون هناك ما يحفظ، وrolling: true يعيد ضبط مدة الخمول مع كل طلب، أي أن maxAge هنا هي الـ Idle Timeout، أما ABSOLUTE فهي الانتهاء المطلق بعد 8 ساعات من الدخول.
  • req.session.regenerate في مسار الدخول هي حماية تثبيت الجلسة، ونقلنا السلة يدوياً إلى الجلسة الجديدة حتى لا يخسرها الزائر.
  • req.session.destroy تحذف الجلسة من المخزن، وclearCookie تطلب من المتصفح حذف ملفه.
  • VERIFY يحدد الخوارزمية المسموحة والمصدر والجمهور، فالمكتبة لا تأخذ الخوارزمية من التوكن، وترفض أي توكن صدر لجمهور آخر.

ونولد الأسرار ونشغل نسخة واحدة من التطبيق والـ Proxy، كل واحد في نافذة طرفية Terminal، ونجعل عمر التوكن دقيقة واحدة حتى نرى انتهاءه دون انتظار طويل:

export SESSION_SECRET="$(openssl rand -base64 32)"
export JWT_SECRET="$(openssl rand -base64 32)"
JWT_TTL=60 NAME=app-a PORT=47811 node app.js
BACKENDS=127.0.0.1:47811 node proxy.js

الخطوة 1: نزور ملف تعريف الارتباط البسيط

نسجل الدخول بالطريقة البسيطة:

curl -sk -i -d 'username=sara&password=correct-horse-battery' https://127.0.0.1:47810/naive/login

والمخرج سوف يكون كما يلي:

HTTP/1.1 200 OK
set-cookie: user=sara; Path=/
content-type: text/html; charset=utf-8
content-length: 13
etag: W/"d-ycthsJYmtTwtdiMnTVVguLA6zbo"
date: Mon, 05 Oct 2026 22:11:15 GMT
connection: keep-alive
keep-alive: timeout=5

welcome sara

ولاحظ أن ملف تعريف الارتباط هو اسم المستخدم كما هو، دون أي خاصية حماية، والآن لا نحتاج كلمة المرور أصلاً، فنكتب ملف تعريف الارتباط بأيدينا:

curl -sk -b 'user=sara' https://127.0.0.1:47810/naive/me
curl -sk -b 'user=admin' https://127.0.0.1:47810/naive/me
naive: you are sara
naive: you are admin

وبهذا الشكل أصبحنا المدير بسطر واحد، وهذا بالضبط سبب القاعدة التي بدأنا بها: السيرفر صدق قيمة كتبها العميل.

الخطوة 2: جلسة صحيحة على السيرفر

نضيف كتاباً إلى السلة دون تسجيل دخول، وهو الطلب الذي رأينا رده كاملاً في قسم Set-Cookie وCookie، ثم ننظر في الملف الذي حفظ فيه curl ملفات تعريف الارتباط، ونحفظ المعرف الأول في متغير:

curl -sk -i -c jar.txt -b jar.txt -d 'item=book' https://127.0.0.1:47810/cart/add
cat jar.txt
OLD=$(grep sid jar.txt | awk '{print $7}')
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

#HttpOnly_127.0.0.1	FALSE	/	TRUE	1791239095	__Host-sid	s%3A5IbRhRR2C_Yw9Si2JnpsrOyGoSQ2YPA_.0h0Pz2HI2cYFW9gcYXvIlRbvb1IROdW79uD0%2Fzhezh0

ولاحظ أن curl كتب #HttpOnly_ قبل اسم المضيف، والعمود TRUE يعني Secure، والرقم بعده وقت الانتهاء بعد 30 دقيقة. والآن نسجل الدخول بنفس وعاء ملفات تعريف الارتباط:

curl -sk -i -c jar.txt -b jar.txt -d 'username=sara&password=correct-horse-battery' https://127.0.0.1:47810/login
curl -sk -b jar.txt https://127.0.0.1:47810/me
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 13
etag: W/"d-ycthsJYmtTwtdiMnTVVguLA6zbo"
set-cookie: __Host-sid=s%3AkHwUrhpYyt0XQsWyX68WmmUgtSXZkYcZ.CWRWI4u38KRl40UVR%2Bxda6iH0ZAlcYyEt5u%2FpBbPRgk; Path=/; Expires=Mon, 05 Oct 2026 22:25:03 GMT; HttpOnly; Secure; SameSite=Lax
date: Mon, 05 Oct 2026 21:55:03 GMT
connection: keep-alive
keep-alive: timeout=5

welcome sara
app-a: you are sara, cart: book

ولاحظ أن المعرف تغير من 5IbRhRR2… إلى kHwUrhpY… والسلة بقيت، وهذا ما يقوله سجل التطبيق أيضاً. والآن نجرب ما كان سوف يفعله مخترق ثبت المعرف الأول في متصفح الضحية قبل أن تسجل الدخول:

curl -sk -b "__Host-sid=$OLD" https://127.0.0.1:47810/me
app-a: not logged in

المعرف القديم لم يعد يعني شيئاً، فهجوم تثبيت الجلسة فشل. ونجرب أيضاً أن نزور القيمة، مرة بتغيير حرف في المعرف ومرة بكتابة قيمة من عندنا:

T=$(grep sid jar.txt | awk '{print $7}' | sed 's/s%3A./s%3AX/')
curl -sk -b "__Host-sid=$T" https://127.0.0.1:47810/me
curl -sk -b "__Host-sid=s%3Aadmin" https://127.0.0.1:47810/me

فيرفض التطبيق الاثنتين، لأن الأولى معرف لا يطابق توقيعه، والثانية قيمة لم يصدرها السيرفر أصلاً:

app-a: not logged in
app-a: not logged in

والآن نحفظ المعرف الحالي كأن أحداً سرقه، ثم نسجل الخروج، ثم يحاول السارق استخدام نسخته:

NEW=$(grep sid jar.txt | awk '{print $7}')
curl -sk -i -c jar.txt -b jar.txt -X POST https://127.0.0.1:47810/logout
curl -sk -b "__Host-sid=$NEW" https://127.0.0.1:47810/me
HTTP/1.1 200 OK
set-cookie: __Host-sid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax
content-type: text/html; charset=utf-8
content-length: 4
etag: W/"4-7p5RRY9GQvSO/pVpYgWCRe5xJ7E"
date: Mon, 05 Oct 2026 21:55:05 GMT
connection: keep-alive
keep-alive: timeout=5

bye
app-a: not logged in

النسخة المسروقة توقفت لحظة الخروج، لأن الجلسة حذفت من السيرفر، والتاريخ 1970 في Set-Cookie هو الطريقة المعتادة لطلب حذف الملف من المتصفح. وسجل التطبيق يلخص ما حدث:

[app-a] listening on 127.0.0.1:47811, sessions in memory
[app-a] cart for session 5IbRhRR2…: book
[app-a] login sara: session 5IbRhRR2… -> kHwUrhpY…
[app-a] logout: session kHwUrhpY… destroyed

الخطوة 3: نصدر JWT ونعبث به

نطلب توكناً، ونقرأ ترويسته ومحتواه دون أي مفتاح، ثم نستدعي ال API به:

TOKEN=$(curl -sk -d 'username=sara&password=correct-horse-battery' https://127.0.0.1:47810/api/token | sed 's/.*"token":"\([^"]*\)".*/\1/')
echo "$TOKEN" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
curl -sk -i -H "Authorization: Bearer $TOKEN" https://127.0.0.1:47810/api/me
{"alg":"HS256","typ":"JWT"}
{"sub":"sara","role":"user","iat":1791237575,"exp":1791237635,"aud":"lab-api","iss":"https://auth.example.com","jti":"853efe7a-b64a-49b4-8a78-493909de999e"}
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 64
etag: W/"40-+trcWlXHVvEfY7tyKCKrqMXW7DM"
date: Mon, 05 Oct 2026 21:59:36 GMT
connection: keep-alive
keep-alive: timeout=5

{"served_by":"app-a","sub":"sara","role":"user","expires_in":59}

ولاحظ أننا قرأنا المحتوى كاملاً بالأداة base64 وحدها، وأن الفرق بين exp وiat هو 60 ثانية كما طلبنا، و2>/dev/null فقط لإخفاء تحذير base64 من علامات = التي يحذفها Base64url. والآن نغير الدور من user إلى admin في المحتوى ونترك التوقيع كما هو:

H=$(echo "$TOKEN" | cut -d. -f1); P=$(echo "$TOKEN" | cut -d. -f2); S=$(echo "$TOKEN" | cut -d. -f3)
FAKE=$(echo "$P" | tr '_-' '/+' | base64 -d 2>/dev/null | sed 's/"role":"user"/"role":"admin"/' | base64 -w0 | tr '+/' '-_' | tr -d '=')
curl -sk -H "Authorization: Bearer $H.$FAKE.$S" https://127.0.0.1:47810/api/me; echo
{"error":"JsonWebTokenError: invalid signature"}

ثم نجرب هجوم alg: none، أي ترويسة تقول إن التوكن غير موقع، مع المحتوى المزور وتوقيع فارغ:

NONE=$(printf '{"alg":"none","typ":"JWT"}' | base64 -w0 | tr '+/' '-_' | tr -d '=')
curl -sk -H "Authorization: Bearer $NONE.$FAKE." https://127.0.0.1:47810/api/me; echo
{"error":"JsonWebTokenError: jwt signature is required"}

والآن نسجل «الخروج» من جهة العميل، أي نحذف التوكن من التطبيق، ولكن نسخة منه ما زالت في المتغير TOKEN كأن أحداً سرقها، فنستدعي ال API بها:

curl -sk -H "Authorization: Bearer $TOKEN" https://127.0.0.1:47810/api/me; echo
{"served_by":"app-a","sub":"sara","role":"user","expires_in":57}

التوكن ما زال يعمل، فلا يوجد في السيرفر شيء نحذفه، وهذا هو الفرق الذي رأيناه في صورة الإلغاء. وبعد أن تمر الدقيقة نعيد نفس الأمر:

{"error":"TokenExpiredError: jwt expired"}

وسجل التطبيق يبين سبب كل رفض:

[app-a] listening on 127.0.0.1:47811, sessions in memory
[app-a] rejected token: JsonWebTokenError: invalid signature
[app-a] rejected token: JsonWebTokenError: jwt signature is required
[app-a] rejected token: TokenExpiredError: jwt expired

الخطوة 4: نسختان من التطبيق، ثم مخزن مشترك

نوقف التطبيق والـ Proxy، ثم نشغل نسختين من التطبيق بالجلسات في الذاكرة، ونجعل الـ Proxy يوزع بينهما:

NAME=app-a PORT=47811 node app.js
NAME=app-b PORT=47812 node app.js
BACKENDS=127.0.0.1:47811,127.0.0.1:47812 node proxy.js

ثم نسجل الدخول ونطلب الصفحة أربع مرات:

rm -f jar.txt
curl -sk -c jar.txt -b jar.txt -d 'username=sara&password=correct-horse-battery' https://127.0.0.1:47810/login
for i in 1 2 3 4; do curl -sk -b jar.txt https://127.0.0.1:47810/me; done
welcome sara
app-b: not logged in
app-a: you are sara, cart: empty
app-b: not logged in
app-a: you are sara, cart: empty

الدخول تم على app-a، فكل طلب يصل إلى app-b يظهر وكأن المستخدم خرج، وهذه هي الحالة التي يشتكي فيها المستخدمون من أن الموقع «يخرجهم كل قليل» بعد إضافة نسخة ثانية. والآن نشغل Valkey بكلمة مرور، ودون حفظ على القرص لأنها تجربة:

export VALKEY_PASS="$(openssl rand -hex 24)"
docker run -d --name lab-session-valkey -p 127.0.0.1:47819:6379 valkey/valkey:9.1.2-alpine \
  valkey-server --requirepass "$VALKEY_PASS" --save "" --appendonly no

ونعيد تشغيل النسختين مع عنوان المخزن، والـ Proxy كما هو:

export REDIS_URL="redis://:[email protected]:47819"
NAME=app-a PORT=47811 node app.js
NAME=app-b PORT=47812 node app.js

ونكرر السلة والدخول والطلبات الأربعة:

rm -f jar.txt
curl -sk -c jar.txt -b jar.txt -d 'item=book' https://127.0.0.1:47810/cart/add
curl -sk -c jar.txt -b jar.txt -d 'username=sara&password=correct-horse-battery' https://127.0.0.1:47810/login
for i in 1 2 3 4; do curl -sk -b jar.txt https://127.0.0.1:47810/me; done
cart: book
welcome sara
app-a: you are sara, cart: book
app-b: you are sara, cart: book
app-a: you are sara, cart: book
app-b: you are sara, cart: book

ولاحظ في السجلين أن السلة أضيفت على app-a والدخول تم على app-b، ومع ذلك انتقلت السلة إلى الجلسة الجديدة، لأن النسختين تقرآن من مكان واحد:

[app-a] listening on 127.0.0.1:47811, sessions in Valkey
[app-a] cart for session q-iDfWyv…: book
[app-b] listening on 127.0.0.1:47812, sessions in Valkey
[app-b] login sara: session q-iDfWyv… -> ZRZhHvOu…

وننظر داخل Valkey لنرى ما يحفظه التطبيق فعلاً:

docker exec lab-session-valkey valkey-cli -a "$VALKEY_PASS" --no-auth-warning KEYS 'sess:*'
docker exec lab-session-valkey valkey-cli -a "$VALKEY_PASS" --no-auth-warning GET sess:ZRZhHvOuI0EngtADoLBAybiLjn-iXypV
docker exec lab-session-valkey valkey-cli -a "$VALKEY_PASS" --no-auth-warning TTL sess:ZRZhHvOuI0EngtADoLBAybiLjn-iXypV
sess:ZRZhHvOuI0EngtADoLBAybiLjn-iXypV
{"cookie":{"originalMaxAge":1799999,"expires":"2026-10-05T22:35:34.651Z","secure":true,"httpOnly":true,"path":"/","sameSite":"lax"},"user":"sara","cart":["book"],"loginAt":1791237934560}
1778

ولاحظ ثلاثة أشياء: يوجد مفتاح واحد فقط، لأن regenerate حذفت جلسة ما قبل الدخول، والبيانات كلها على السيرفر وليس في المتصفح، وTTL هي الثواني الباقية من مدة الخمول، فإذا لم يعد المستخدم يحذف Valkey المفتاح وحده. وبعد POST /logout يختفي المفتاح ولا يعيد KEYS أي شيء، ويسجل التطبيق [app-a] logout: session ZRZhHvOu… destroyed.

وأخيراً نجرب قائمة الإلغاء للتوكنات، وهي الآن في Valkey أيضاً، فتراها النسختان:

TOKEN=$(curl -sk -d 'username=sara&password=correct-horse-battery' https://127.0.0.1:47810/api/token | sed 's/.*"token":"\([^"]*\)".*/\1/')
curl -sk -H "Authorization: Bearer $TOKEN" https://127.0.0.1:47810/api/me; echo
curl -sk -H "Authorization: Bearer $TOKEN" https://127.0.0.1:47810/api/me; echo
curl -sk -X POST -H "Authorization: Bearer $TOKEN" https://127.0.0.1:47810/api/revoke; echo
curl -sk -H "Authorization: Bearer $TOKEN" https://127.0.0.1:47810/api/me; echo
curl -sk -H "Authorization: Bearer $TOKEN" https://127.0.0.1:47810/api/me; echo
{"served_by":"app-a","sub":"sara","role":"user","expires_in":299}
{"served_by":"app-b","sub":"sara","role":"user","expires_in":298}
{"revoked":"5dedf956-1408-4e5e-9f4a-eff54a47ff43"}
{"error":"token revoked"}
{"error":"token revoked"}

والسجل يقول [app-a] revoked jti 5dedf956… for 298s، أي أن المفتاح deny:5dedf956-… سوف يحذف وحده عندما ينتهي التوكن، فلا تكبر القائمة إلى الأبد. ولكن تذكر أن كل طلب إلى ال API أصبح يسأل Valkey، أي أننا عدنا إلى مخزن مشترك كما في الجلسات.

إزالة التجربة

نوقف التطبيقين والـ Proxy بالضغط على Ctrl+C في كل نافذة، ثم نحذف الـ Container والـ Image والمجلد:

docker rm -f lab-session-valkey
docker rmi valkey/valkey:9.1.2-alpine
cd .. && rm -rf lab-session

قائمة أفضل الممارسات

  • اجعل ملف الجلسة __Host- مع Secure وHttpOnly وSameSite=Lax وPath=/، دون Domain، واختر له اسماً عاماً لا يكشف الإطار، فالأسماء مثل PHPSESSID وconnect.sid تخبر المخترق بما تستخدم.
  • ولد معرف الجلسة بمولد عشوائي آمن من مكتبة الإطار، ولا تقم بكتابة مولد بنفسك، ولا تبن المعرف من اسم المستخدم أو الوقت.
  • غير معرف الجلسة بعد الدخول وبعد أي تغيير في الصلاحيات، واحذف الجلسة من السيرفر عند الخروج.
  • ضع مدتين للجلسة: مدة خمول قصيرة، ومدة مطلقة من وقت الدخول، وأقصر كلما كان التطبيق أخطر.
  • استخدم مخزناً مشتركاً مثل Valkey أو Redis أو قاعدة البيانات عندما يكون لديك أكثر من نسخة، واحمه بكلمة مرور واجعله على شبكة داخلية.
  • للتطبيقات التي تعمل في المتصفح ابدأ بالجلسات، واستخدم JWT حيث يحل مشكلة حقيقية: APIs وخدمات متعددة وتطبيقات جوال.
  • حدد خوارزمية JWT في كود التحقق، وتحقق دائماً من exp وiss وaud، واستخدم مكتبة معروفة محدثة.
  • اجعل سر HS256 عشوائياً بطول 256 بت على الأقل، وإذا كانت هناك أكثر من خدمة تتحقق فانتقل إلى RS256 أو ES256 مع JWKS.
  • اجعل عمر الـ Access Token قصيراً مع Refresh Token يدور مع كل استخدام، وجهز قائمة إلغاء بالـ jti إذا كنت تحتاج الإلغاء الفوري.
  • لا تضع في محتوى JWT أي معلومة سرية، ولا تحفظ التوكنات طويلة العمر في localStorage.
  • احم التطبيق من CSRF بالخاصية SameSite وتوكن CSRF وفحص Origin، ومن XSS بترميز المخرجات وCSP.
  • لا تقم بتسجيل معرفات الجلسات أو التوكنات كاملة في السجلات Logs، فمن يقرأ السجلات يصبح قادراً على الدخول باسم المستخدمين.

أخطاء شائعة وكيف تعرفها

  • يدخل المستخدم ثم يخرج مباشرة، أو لا يظهر Set-Cookie أصلاً: التطبيق خلف Reverse Proxy ينهي HTTPS، ولا يثق بالترويسة X-Forwarded-Proto، فيرى الطلب HTTP ولا يرسل ملفاً يحمل Secure، والحل في دليل تطبيقك خلف Reverse Proxy.
  • يخرج المستخدم كل بضعة طلبات: أكثر من نسخة من التطبيق والجلسات في الذاكرة، كما رأينا في الخطوة 4، أو أن لكل نسخة سراً مختلفاً لتوقيع ملف الجلسة.
  • يخرج الجميع بعد كل نشر Deploy: الجلسات في ذاكرة التطبيق، أو أن سر الجلسة يولد عشوائياً عند كل تشغيل بدلاً من قراءته من متغير بيئة ثابت.
  • لا يرجع ملف الجلسة بعد العودة من مزود الدخول: الخاصية SameSite=Strict على ملف يحتاج أن يرجع بعد تحويل من موقع آخر، وغالباً Lax هي القيمة الصحيحة.
  • ملف الجلسة لا يصل إلى ال API في نطاق آخر: الطلب من نطاق مختلف لا يحمل ملفات تعريف الارتباط إلا مع credentials: "include" وإعداد CORS صحيح، كما في قسم بيانات الدخول في CORS، وإذا كان النطاق موقعاً آخر Cross-Site فقد تمنعه سياسات ملفات الطرف الثالث، والحل الأنسب نطاق واحد.
  • المستخدم الذي عطلته ما زال يستدعي ال API: التوكن الذي في يده صالح حتى exp، فقصر العمر أو أضف قائمة إلغاء.
  • الخطأ invalid signature لكل التوكنات فجأة: تغير السر أو المفتاح في إحدى الخدمات، أو أن الخدمات لا تحمل نفس القيمة، أو أن مزود الهوية غير مفتاحه والخدمة تحفظ JWKS قديمة.
  • الخطأ jwt expired مع توكن جديد: ساعة السيرفر غير مضبوطة، فتحقق من مزامنة الوقت عبر NTP في كل السيرفرات.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • HTTP لا يتذكر أحداً، وكل طريقة تجعل السيرفر يصدق قيمة يكتبها العميل عن هويته يمكن تزويرها بأمر curl واحد.
  • ملف تعريف الارتباط هو المكان الذي يحفظ فيه المتصفح القيمة ويعيدها، وخصائصه Secure وHttpOnly وSameSite والبادئة __Host- هي ما يجعله آمناً، والمعيار ما زال RFC 6265 ومسودة 6265bis لم تنشر بعد.
  • Safari وFirefox يقيدان ملفات الطرف الثالث افتراضياً، وChrome تراجع عن إلغائها في 2024 و2025، فلا تبن تطبيقك على ملفات الطرف الثالث.
  • الجلسة على السيرفر معرف عشوائي في ملف تعريف الارتباط وبيانات في مخزن، يتغير معرفها بعد الدخول، ولها مدة خمول ومدة مطلقة، وتحذف فوراً عند الخروج، وتحتاج مخزناً مشتركاً مثل Valkey مع أكثر من نسخة.
  • JWT بيانات موقعة يقرؤها أي أحد ولا يغيرها إلا صاحب المفتاح، ويناسب ال APIs والخدمات المتعددة، وثمنه أنك لا تستطيع إلغاءه قبل exp إلا بقائمة إلغاء، ويجب أن تحدد خوارزميته بنفسك وتتحقق من aud وiss وexp.
  • للتطبيقات التي تعمل في المتصفح، الجلسة في ملف HttpOnly هي الحل الأبسط والأكثر أماناً، ومع SPA استخدم BFF، ولا تحفظ التوكنات في localStorage، واحم التطبيق من CSRF بالخاصية SameSite والتوكن وفحص Origin، ومن XSS بـ CSP.
دليلشرح OAuth وOpenID Connect للمبتدئينلماذا لا يجب أن يعرف أي تطبيق كلمة مرورك، وكيف يحل OAuth ذلك بتوكنات محدودة، وكيف يضيف OpenID Connect الدخول الموحد.دليلHSTS وCSP: ترويسات الأمان التي يحتاجها كل تطبيقما الذي تطلبه كل ترويسة أمان من المتصفح، ولماذا يصعب التراجع عن HSTS، وكيف تبني سياسة CSP لا تكسر تطبيقك.دليلCORS: لماذا يرفض المتصفح طلبك، وكيف تسمح به بأمانلماذا يمنع المتصفح صفحتك من قراءة رد ال API، وكيف تفتح CORS هذا الباب لمصادر محددة دون أن تفتحه لكل موقع.دليلالدخول دون كلمة مرور: شرح Passkeys وWebAuthnلماذا لا تحميك رموز SMS وTOTP من التصيد الحديث، وكيف تحل Passkeys المشكلة بمفتاح خاص لا يغادر جهازك.دليلكيف يرى تطبيقك عنوان الزائر الحقيقي خلف ال Reverse Proxyإذا كان تطبيقك يرى عنوان ال Proxy بدلاً من الزائر أو يولد روابط http، فهذا هو السبب والإعداد الصحيح لإطار عملك.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجربة العملية على Node.js 22.14.0 مع express 5.2.1 وexpress-session 1.19.0 وjsonwebtoken 9.0.3، ومخزن الجلسات Valkey 9.1.2.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.