تفتح متجراً إلكترونياً وتضيف كتاباً إلى السلة، ثم تنتقل إلى صفحة أخرى فتجد الكتاب ما زال في السلة، وتسجل الدخول مرة واحدة في الصباح فيبقى اسمك أعلى الصفحة طوال اليوم، وهذا يبدو أمراً طبيعياً جداً حتى تعرف أن البروتوكول الذي يحمل هذه الصفحات، أي 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 يصل إلى السيرفر مستقلاً تماماً عن الطلب الذي قبله، فالسيرفر يقرأ الطلب ويرد عليه ثم ينساه، ولا يوجد في البروتوكول نفسه أي شيء يقول «هذا الطلب جاء من نفس الشخص الذي سجل الدخول قبل دقيقة». وهذا التصميم مقصود، لأنه يجعل السيرفرات بسيطة وسهلة التوسع، فأي سيرفر يستطيع أن يرد على أي طلب، ولكن المشكلة أن المواقع التي نستخدمها ليست عديمة الحالة: هناك سلة مشتريات، ومستخدم مسجل الدخول، ولغة اختارها الزائر، وكل هذا يجب أن يبقى بين الصفحات. والصورة التالية تبين المشكلة:

والحلول الأولى التي تخطر على بال أي مطور هي التالية، وكلها فاشلة لنفس السبب تقريباً:
- اسم المستخدم في الرابط: بعد الدخول تضيف إلى كل رابط
?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 وCookie في طلب حقيقي
الحوار كله يتم في ترويستين: السيرفر يرسل 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 في 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 للمعرف بسر السيرفر، وهذا التوقيع ليس ضرورياً لأمان الجلسة لأن المعرف عشوائي أصلاً، ولكنه يجعل السيرفر يرفض أي قيمة لم يصدرها هو قبل أن يبحث عنها في المخزن.
دورة حياة الجلسة: من الدخول إلى الخروج
- الإنشاء: تبدأ الجلسة عندما يحتاج التطبيق أن يتذكر شيئاً، مثل إضافة منتج إلى السلة قبل تسجيل الدخول، أو عند الدخول نفسه.
- تغيير المعرف بعد الدخول Session ID Regeneration: لحظة نجاح الدخول ينشئ التطبيق معرفاً جديداً وينقل إليه البيانات ويحذف القديم، والسبب هجوم تثبيت الجلسة Session Fixation: إذا استطاع المخترق أن يضع في متصفح الضحية معرف جلسة يعرفه هو، عبر رابط أو نطاق فرعي مخترق، ثم سجل الضحية الدخول بنفس المعرف، فإن المخترق يصبح مسجلاً باسمه. ولذلك ينص دليل OWASP على تغيير المعرف بعد أي تغيير في مستوى الصلاحية، أي عند الدخول، وعند تفعيل صلاحيات المدير، وعند تغيير كلمة المرور.
- انتهاء الخمول Idle Timeout: إذا لم يستخدم المستخدم الجلسة مدة معينة تنتهي، ويقترح دليل OWASP من 2 إلى 5 دقائق للتطبيقات عالية القيمة، ومن 15 إلى 30 دقيقة للتطبيقات منخفضة الخطورة.
- الانتهاء المطلق Absolute Timeout: حتى لو بقي المستخدم نشطاً، تنتهي الجلسة بعد مدة ثابتة من الدخول، مثل 4 إلى 8 ساعات لموظف يعمل يوماً كاملاً، والسبب أن الجلسة المسروقة لا يجب أن تعيش للأبد لمجرد أن المخترق يستخدمها باستمرار.
- الخروج Logout: يحذف التطبيق الجلسة من المخزن، وليس من المتصفح فقط، ثم يرسل ملف تعريف ارتباط منتهياً حتى يحذفه المتصفح، والجزء الأول هو المهم، لأن من نسخ القيمة قبل الخروج لن تنفعه بعد حذفها من السيرفر.
عندما يعمل التطبيق على أكثر من نسخة
الطريقة الأسهل أن يحفظ كل تطبيق الجلسات في ذاكرته، وهذا يعمل ما دامت هناك نسخة واحدة، ولكن عندما تضع نسختين خلف موزع حمل Load Balancer تظهر المشكلة: المستخدم يسجل الدخول على النسخة الأولى، والطلب التالي يصل إلى النسخة الثانية التي لا تعرفه، فيظهر وكأنه خرج، وإذا أعيد تشغيل التطبيق خرج كل المستخدمين. والحلان المعروفان:
- الجلسات اللاصقة Sticky Sessions: يرسل الـ Load Balancer كل زائر إلى نفس النسخة دائماً، وهذا حل سريع ولكنه هش، لأن سقوط النسخة أو إعادة تشغيلها يخرج كل من عليها، وتوزيع الحمل يصبح غير متساو.
- مخزن جلسات مشترك Shared Session Store: كل النسخ تقرأ وتكتب الجلسات في مكان واحد، وأشهر اختيار هو Redis أو Valkey، وهو مشروع مفتوح المصدر برخصة BSD تدعمه Linux Foundation، مبني على كود Redis ويتكلم البروتوكول نفسه، لذلك تعمل معه مكتبات Redis دون تغيير. وميزة هذه المخازن أن كل مفتاح له مدة انتهاء TTL، فتحذف الجلسات المنتهية وحدها، ويمكن أيضاً استخدام قاعدة البيانات نفسها مثل PostgreSQL إذا كان عدد الطلبات معقولاً.

ولاحظ أن المخزن الافتراضي في 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، فلن نكرره هنا. والصورة التالية تبين كيف يستخدم التوكن من إصداره إلى التحقق منه:

وأهم ما تتذكره أن 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. والصورة التالية تبين الفرق:

والحلول المعروفة كلها تقلل المشكلة ولا تلغيها:
- عمر قصير مع توكن تجديد: يكون الـ 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. - توكن طويل العمر دون تجديد: توكن صالح لشهر لأن «التجديد معقد» هو كلمة مرور ثانية لا يمكن إلغاؤها.
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، حيث يجعل موقع آخر متصفح الضحية يرسل إلى موقعك طلباً يحمل ملف الجلسة تلقائياً.

localStorageوsessionStorage: أسهل مكان، وأي سكربت في الصفحة يقرؤه، فثغرة XSS واحدة، أو مكتبة JavaScript مخترقة تحملها الصفحة، تعني أن التوكن خرج إلى جهاز المخترق ويعمل من هناك حتى ينتهي، وlocalStorageلا ينتهي وحده، لذلك لا تقم بحفظ توكن طويل العمر فيه، وقد نصح دليل OWASP لأمان HTML5 بعدم حفظ معرفات الجلسة فيه.- متغير في الذاكرة: يضيع عند تحديث الصفحة، والسكربت المحقون لا يستطيع غالباً أن يقرأه إذا كان مخفياً داخل الكود، ولكنه يستطيع أن يرسل الطلبات باسم المستخدم ما دامت الصفحة مفتوحة، ويحتاج التطبيق طريقة ليحصل على توكن جديد بعد التحديث، وغالباً تكون ملف تعريف ارتباط.
- ملف تعريف ارتباط
HttpOnly: لا يقرؤه أي سكربت، فلا يخرج من المتصفح حتى مع XSS، وإن كان السكربت المحقون يستطيع أن يرسل الطلبات من داخل الصفحة ما دامت مفتوحة. وثمن ذلك أن المتصفح يرسله تلقائياً، فيجب أن تحمي التطبيق من CSRF.
والحماية من CSRF ثلاث طبقات، وتفصيلها في دليل OWASP لمنع CSRF:
SameSite=LaxأوStrict: لا يرسل المتصفح ملف الجلسة مع نموذج POST أو طلب خفي يبدأ من موقع آخر، وهذه أهم طبقة وأرخصها.- توكن CSRF: قيمة عشوائية يضعها السيرفر في الصفحة ويطلبها مع كل طلب يغير البيانات، والموقع الآخر لا يستطيع قراءتها، وأغلب الأطر تضيفه جاهزاً، فلا تقم بتعطيله.
- فحص
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.jsBACKENDS=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/menaive: 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/meHTTP/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/meapp-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/meHTTP/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; donewelcome 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; donecart: 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-iXypVsess: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.