> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# كيف يتذكرك الموقع بعد تسجيل الدخول؟ الجلسات وJWT
- URL: https://arabroot.io/articles/الجلسات-وملفات-تعريف-الارتباط-و-jwt-للمبتدئين/
- Published: 2026-10-05T11:00:00.000Z
- Updated: 2026-10-09T09:42:08.000Z
- Description: بروتوكول HTTP لا يتذكر أحداً، فكيف يبقى المستخدم مسجلاً بين الصفحات، ومتى تختار الجلسة على السيرفر ومتى تختار JWT.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, الأمن السيبراني, DevOps وCI/CD, الاستضافة الذاتية

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

وفي مقال [OAuth وOpenID Connect](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-oauth-%D9%88-openid-connect-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/) ومقال [إدارة الهوية والوصول IAM](https://arabroot.io/articles/%D8%A7%D8%AF%D8%A7%D8%B1%D8%A9-%D8%A7%D9%84%D9%87%D9%88%D9%8A%D8%A9-%D9%88%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-iam-%D9%88%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-%D8%A7%D9%84%D9%85%D9%85%D9%8A%D8%B2-pam/) تكلمنا عن من يثبت هوية المستخدم ومن يعطيه الصلاحيات، وهذا المقال يرجع خطوة إلى الأساس الذي تقوم عليه كل تلك الأنظمة: بعد أن يثبت المستخدم هويته مرة، كيف يتذكره التطبيق في الطلبات التالية؟ وسوف نناقش في هذا المقال ما يلي:

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

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

[بروتوكول HTTP](https://arabroot.io/articles/http-%D9%88-https-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#stateless) عديم الحالة Stateless، وهذا يعني أن كل طلب Request يصل إلى السيرفر مستقلاً تماماً عن الطلب الذي قبله، فالسيرفر يقرأ الطلب ويرد عليه ثم ينساه، ولا يوجد في البروتوكول نفسه أي شيء يقول «هذا الطلب جاء من نفس الشخص الذي سجل الدخول قبل دقيقة». وهذا التصميم مقصود، لأنه يجعل السيرفرات بسيطة وسهلة التوسع، فأي سيرفر يستطيع أن يرد على أي طلب، ولكن المشكلة أن المواقع التي نستخدمها ليست عديمة الحالة: هناك سلة مشتريات، ومستخدم مسجل الدخول، ولغة اختارها الزائر، وكل هذا يجب أن يبقى بين الصفحات. والصورة التالية تبين المشكلة:

![مخطط يبين متصفحاً يرسل ثلاثة طلبات إلى السيرفر، الأول تسجيل دخول ينجح، والثاني والثالث يرد عليهما السيرفر بالرمز 401 لأنه لا يعرف من المرسل، وتحتها ثلاث أفكار بسيطة مرفوضة: اسم المستخدم في الرابط، وحقل مخفي في النموذج، وملف تعريف ارتباط يقول user=sara](https://arabroot.io/content/images/2026/10/sessions-cookies-jwt-01-stateless.webp)

السيرفر لا يربط الطلب الثاني بالأول، والأفكار البسيطة كلها تثق بقيمة يكتبها العميل

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

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

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

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

ملف تعريف الارتباط Cookie هو اسم وقيمة صغيرة يطلب السيرفر من المتصفح أن يحفظها، فيعيدها المتصفح تلقائياً مع كل طلب لاحق إلى نفس الموقع، كما يشرح [دليل ملفات تعريف الارتباط في MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies?ref=arabroot.io). وقد أصبحت معياراً في [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265?ref=arabroot.io) في أبريل 2011، وما زال هذا هو المعيار الرسمي، أما التحديث الذي يضيف `SameSite` والبادئات وغيرها، واسمه [RFC 6265bis](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/?ref=arabroot.io)، فما زال مسودة Internet-Draft حتى كتابة هذا المقال (الإصدار 22 بتاريخ 1 ديسمبر 2025، وحالته «أرسل إلى IESG للنشر»)، مع أن المتصفحات تطبق أغلب ما فيه منذ سنوات.

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

### Set-Cookie وCookie في طلب حقيقي

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

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

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

```http
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 هنا يتصرف كمتصفح صغير، وبعد ذلك يرسل مع كل طلب السطر التالي فقط:

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

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

![مخطط يبين المتصفح ووعاء ملفات تعريف الارتباط على اليسار والسيرفر على اليمين، في الخطوة الأولى يرسل المتصفح طلب الدخول، وفي الثانية يرد السيرفر بالترويسة Set-Cookie مع خصائصها فيدخل ملف تعريف الارتباط إلى الوعاء، وفي الثالثة يعيده المتصفح في الترويسة Cookie مع كل طلب، وتحتها بطاقة تشرح الخصائص Domain وPath وExpires وMax-Age وSecure وHttpOnly وSameSite والبادئة __Host- وPartitioned](https://arabroot.io/content/images/2026/10/sessions-cookies-jwt-02-cookie-flow.webp)

يضع السيرفر ملف تعريف الارتباط مرة، ويعيده المتصفح مع كل طلب حسب الخصائص

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

هذه الخصائص هي الفرق بين ملف تعريف ارتباط آمن وآخر يسرق في أول ثغرة، وتفصيلها في [توثيق Set-Cookie في MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie?ref=arabroot.io):

- **`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](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%83-%D8%AE%D9%84%D9%81-reverse-proxy/#https-awareness).
- **`HttpOnly`:** لا يستطيع JavaScript قراءته عبر `document.cookie`، ويبقى المتصفح يرسله مع الطلبات كالمعتاد، وهذه الخاصية تمنع أشهر طريقة لسرقة الجلسة، وهي سكربت محقون عبر ثغرة حقن السكربت Cross-Site Scripting واختصاراً XSS يقرأ ملف تعريف الارتباط ويرسله إلى المخترق.
- **`SameSite`:** يحدد هل يرسل ملف تعريف الارتباط مع الطلبات التي تبدأ من موقع آخر، فالقيمة `Strict` لا ترسله أبداً من موقع آخر، حتى لو ضغط المستخدم رابطاً إلى موقعك من بريده فسوف يصل وكأنه غير مسجل، و`Lax` ترسله عندما ينقل رابط في موقع آخر التبويب كله إلى موقعك بطلب GET، ولا ترسله مع نماذج POST أو الطلبات الخفية من موقع آخر، و`None` ترسله دائماً وتشترط `Secure`، وهذه الخاصية أهم حماية من تزوير الطلب عبر المواقع Cross-Site Request Forgery واختصاراً CSRF كما سيأتي. ومنذ [الإصدار 80 من Chrome في فبراير 2020](https://www.chromium.org/updates/same-site/?ref=arabroot.io) يعامل Chrome ملف تعريف الارتباط الذي لا يحدد `SameSite` كأنه `Lax`، ولكن ليست كل المتصفحات تفعل ذلك، لذلك حددها دائماً بنفسك.
- **البادئتان `__Secure-` و`__Host-`:** إذا بدأ اسم ملف تعريف الارتباط بالبادئة `__Secure-` فلن يقبله المتصفح إلا مع `Secure` ومن صفحة HTTPS، وإذا بدأ بالبادئة `__Host-` فيشترط فوق ذلك `Path=/` وعدم وجود `Domain`، أي أنه مقفل على هذا المضيف بالضبط، فلا يستطيع نطاق فرعي أن يكتب فوقه، وهي أفضل اختيار لملف الجلسة.
- **`Partitioned`:** خاصية جديدة اسمها [CHIPS](https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/Privacy%5Fsandbox/Partitioned%5Fcookies?ref=arabroot.io)، تجعل ملف الطرف الثالث محفوظاً في وعاء منفصل لكل موقع يظهر فيه، فإذا وضعت أداة دردشة في عدة مواقع، فلكل موقع ملف مختلف، ولا تستطيع الأداة أن تتبع الزائر بينها.

ويجدر الإشارة هنا إلى أن `SameSite` تتكلم عن «الموقع» Site وليس عن «المصدر» Origin، والموقع هو النطاق المسجل مع اللاحقة، فالعنوانان `app.example.com` و`blog.example.com` موقع واحد Same-Site مع أنهما مصدران مختلفان في نظر CORS، وهذا سبب آخر لاستخدام `__Host-`. وإذا كان تطبيقك جاهزاً ولا تستطيع تعديل كوده، فقد شرحنا كيف تضيف هذه الخصائص عبر الـ Reverse Proxy في قسم [ملفات تعريف الارتباط](https://arabroot.io/articles/%D8%AA%D8%B1%D9%88%D9%8A%D8%B3%D8%A7%D8%AA-%D8%A7%D9%84%D8%A3%D9%85%D8%A7%D9%86-hsts-%D9%88-csp/#cookies) من دليل ترويسات الأمان.

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

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

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

- **Safari:** يمنع ملفات الطرف الثالث افتراضياً بالكامل منذ [مارس 2020](https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/?ref=arabroot.io) (الإصدار 13.1).
- **Firefox:** فعل [الحماية الكاملة من ملفات تعريف الارتباط Total Cookie Protection](https://blog.mozilla.org/en/mozilla/firefox-rolls-out-total-cookie-protection-by-default-to-all-users-worldwide/?ref=arabroot.io) لكل المستخدمين في يونيو 2022، فأصبح لكل موقع وعاء منفصل، ولا يرى الطرف الثالث في موقع ما ملفه في موقع آخر.
- **Chrome:** أعلنت Google في [يناير 2020](https://blog.chromium.org/2020/01/building-more-private-web-path-towards.html?ref=arabroot.io) أنها سوف تلغي ملفات الطرف الثالث، وأجلت الموعد عدة مرات، ثم في [يوليو 2024](https://privacysandbox.google.com/blog/privacy-sandbox-update?ref=arabroot.io) تراجعت عن الإلغاء واقترحت بدلاً منه خياراً يحدده المستخدم، ثم في [أبريل 2025](https://privacysandbox.google.com/blog/privacy-sandbox-next-steps?ref=arabroot.io) أعلنت أنها لن تضيف حتى هذا الخيار، وأن ملفات الطرف الثالث تبقى مسموحة افتراضياً ويتحكم فيها المستخدم من إعدادات الخصوصية، مع منعها افتراضياً في وضع التصفح المتخفي Incognito. وفي [أكتوبر 2025](https://privacysandbox.google.com/blog/update-on-plans-for-privacy-sandbox-technologies?ref=arabroot.io) أوقفت أغلب تقنيات Privacy Sandbox التي كانت سوف تحل محلها، وأبقت CHIPS وFedCM.

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

### الحجم والعدد

ملف تعريف الارتباط صغير بطبيعته، فالمعيار [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265?ref=arabroot.io#section-6.1) يطلب من المتصفح أن يقبل على الأقل 4096 بايت لكل ملف (الاسم والقيمة والخصائص معاً)، و50 ملفاً لكل نطاق، و3000 ملف في المجموع، والمسودة الجديدة تحدد الاسم والقيمة معاً بـ 4096 بايت وقيمة كل خاصية بـ 1024 بايت. وأهم من الحد نفسه أن كل ملفات النطاق ترسل مع كل طلب، حتى طلبات الصور وملفات CSS، فإذا وضعت فيها 3 كيلوبايت فأنت تضيف 3 كيلوبايت إلى كل طلب، لذلك ضع في ملف تعريف الارتباط معرفاً صغيراً وليس البيانات نفسها.

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

الآن نجمع الأفكار السابقة: السيرفر يولد معرف جلسة Session ID عشوائياً طويلاً، ويضعه في ملف تعريف ارتباط بالخصائص الصحيحة، ويحفظ عنده تحت هذا المعرف كل ما يريد أن يتذكره: من هو المستخدم، ومتى دخل، وما في سلته. والمعرف نفسه لا يحمل أي معلومة، فهو مثل بطاقة المعطف، وتخمينه مستحيل عملياً، لأن [دليل OWASP لإدارة الجلسات](https://cheatsheetseries.owasp.org/cheatsheets/Session%5FManagement%5FCheat%5FSheet.html?ref=arabroot.io) يطلب 64 بت على الأقل من العشوائية Entropy، والمكتبة التي استخدمناها في التجربة [express-session](https://github.com/expressjs/session?ref=arabroot.io) تولد 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](https://valkey.io/?ref=arabroot.io)، وهو مشروع مفتوح المصدر برخصة BSD تدعمه Linux Foundation، مبني على كود Redis ويتكلم البروتوكول نفسه، لذلك تعمل معه مكتبات Redis دون تغيير. وميزة هذه المخازن أن كل مفتاح له مدة انتهاء TTL، فتحذف الجلسات المنتهية وحدها، ويمكن أيضاً استخدام قاعدة البيانات نفسها مثل PostgreSQL إذا كان عدد الطلبات معقولاً.

![مخطط من جزأين، في الأيمن متصفح يرسل ملف الجلسة إلى Proxy يوزع الطلبات على نسختين من التطبيق، النسخة الأولى تحفظ الجلسة في ذاكرتها فترد بأنك سارة، والثانية ذاكرتها فارغة فترد بالرمز 401، وفي الأيسر نفس النسختين تقرآن الجلسة من مخزن مشترك Valkey أو Redis فيه المفتاح sess:7f3a مع المستخدم والسلة، فترد أي نسخة بشكل صحيح](https://arabroot.io/content/images/2026/10/sessions-cookies-jwt-03-session-store.webp)

الجلسة في ذاكرة كل نسخة تضيع عند النسخة الأخرى، والمخزن المشترك يجعل أي نسخة قادرة على الرد

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

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

احفظ في الجلسة ما يحتاجه التطبيق في كل طلب ويتغير مع المستخدم: معرف المستخدم، ووقت الدخول، وطريقة الدخول (كلمة مرور أو [Passkey](https://arabroot.io/articles/%D8%A7%D9%84%D8%AF%D8%AE%D9%88%D9%84-%D8%AF%D9%88%D9%86-%D9%83%D9%84%D9%85%D8%A9-%D9%85%D8%B1%D9%88%D8%B1-passkeys-%D9%88-fido2/) أو SSO)، والسلة، والتوكنات التي حصل عليها التطبيق من مزود الهوية إذا كان يستخدم OAuth. ولا تقم بحفظ نسخة كاملة من صلاحيات المستخدم إذا كانت تتغير كثيراً، والسبب أن المدير إذا سحب صلاحية من موظف فالجلسة سوف تبقى تحمل الصلاحية القديمة حتى تنتهي، فالأفضل أن تحفظ معرف المستخدم وتقرأ صلاحياته من قاعدة البيانات أو من ذاكرة مؤقتة Cache قصيرة. ولا تحفظ كلمة المرور بأي شكل، ولا أي بيانات لا تحتاجها، لأن مخزن الجلسات يصبح هدفاً، فمن يقرؤه يستطيع أن ينسخ أي جلسة فيه، لذلك احمه بكلمة مرور ولا تعرضه على الشبكة.

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

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

والصيغة الأشهر لهذه الورقة هي JSON Web Token واختصاراً JWT (تنطق «جوت»)، المعرفة في [RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519?ref=arabroot.io) في مايو 2015، ويرسلها العميل عادة في الترويسة `Authorization: Bearer …` وليس في ملف تعريف ارتباط، وإن كان من الممكن أن توضع فيه أيضاً.

### مم يتكون JWT؟

يتكون التوكن من ثلاثة أجزاء بينها نقطتان `header.payload.signature`، كل جزء مرمز بترميز Base64url: الترويسة Header وفيها خوارزمية التوقيع، والمحتوى Payload وفيه الحقول Claims، ثم التوقيع Signature المحسوب على الجزأين الأولين، وقد فككنا توكناً حقيقياً جزءاً جزءاً في قسم [كيف تقرأ JWT](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-oauth-%D9%88-openid-connect-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#jwt) من مقال OAuth، فلن نكرره هنا. والصورة التالية تبين كيف يستخدم التوكن من إصداره إلى التحقق منه:

![مخطط يبين عميلاً يسجل الدخول عند خدمة الإصدار فيحصل على JWT من ثلاثة أجزاء، ثم يرسله إلى ال API في الترويسة Authorization، فيتحقق ال API من التوقيع والخوارزمية ووقت الانتهاء والمصدر والجمهور دون أي استعلام في قاعدة البيانات، ثم يرد، وتحته الأجزاء الثلاثة للتوكن والفرق بين HS256 بسر مشترك وRS256 أو ES256 بمفتاح خاص ومفتاح عام من JWKS](https://arabroot.io/content/images/2026/10/sessions-cookies-jwt-04-jwt-flow.webp)

السيرفر يوقع مرة، وال API يتحقق بالمفتاح دون أن يسأل أحداً

وأهم ما تتذكره أن Base64url ترميز Encoding وليس تشفيراً Encryption، فأي أحد يحمل التوكن يقرأ محتواه بأمر واحد كما سوف نرى في التجربة، والتوقيع لا يخفي شيئاً، وإنما يكشف أي تعديل. وهذا النوع الموقع اسمه JWS، ومعرف في [RFC 7515](https://datatracker.ietf.org/doc/html/rfc7515?ref=arabroot.io)، وهو الذي تقصده كل المكتبات عندما تقول JWT. أما إذا كنت تحتاج أن يكون المحتوى سرياً فهناك صيغة مشفرة اسمها JWE في [RFC 7516](https://datatracker.ietf.org/doc/html/rfc7516?ref=arabroot.io)، وتتكون من خمسة أجزاء، واستخدامها أقل بكثير، والأبسط غالباً ألا تضع في التوكن ما يحتاج إلى إخفاء.

### HS256 أم RS256 أم ES256؟

- **`HS256`:** توقيع HMAC مع SHA-256 بسر مشترك Shared Secret واحد، يعرفه من يصدر التوكن ومن يتحقق منه، وهو بسيط وسريع ومناسب عندما يكون المصدر والمتحقق نفس التطبيق، كما في تجربتنا. والعيب أن كل من يستطيع التحقق يستطيع التوقيع أيضاً، فإذا عرفت عشر خدمات السر فأي واحدة منها تستطيع أن تصدر توكن باسم أي مستخدم. ويشترط [RFC 7518](https://datatracker.ietf.org/doc/html/rfc7518?ref=arabroot.io#section-3.2) أن يكون المفتاح بطول ناتج الهاش على الأقل، أي 256 بت لـ `HS256`، وهذا ما يولده الأمر `openssl rand -base64 32`.
- **`RS256` و`ES256`:** توقيع بزوج مفاتيح، RSA في الأولى ومنحنيات بيضاوية Elliptic Curves في الثانية، فالمفتاح الخاص Private Key لا يغادر خدمة الإصدار، والخدمات الأخرى تملك المفتاح العام Public Key فقط، فتتحقق ولا تستطيع أن توقع. وتنشر خدمة الإصدار مفاتيحها العامة في عنوان بصيغة JWKS المعرفة في [RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517?ref=arabroot.io)، فتجلبها الخدمات وتختار المفتاح حسب الحقل `kid` في الترويسة، وهذا يجعل تغيير المفاتيح دورياً Key Rotation ممكناً دون توقف، وقد شرحنا ذلك في قسم [التحقق من التوقيع عبر JWKS](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-oauth-%D9%88-openid-connect-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#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 حتى وقت الانتهاء، وتحتهما طرق تقصير هذه النافذة](https://arabroot.io/content/images/2026/10/sessions-cookies-jwt-05-revocation.webp)

الجلسة تنتهي لحظة حذفها، وال JWT يبقى صالحاً حتى exp مهما حدث على السيرفر

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

- **عمر قصير مع توكن تجديد:** يكون الـ Access Token من 5 إلى 15 دقيقة، ومعه Refresh Token أطول عمراً يحفظ في مكان أكثر أماناً ويستبدل بتوكن جديد مع كل استخدام Rotation، وعند الخروج يلغى توكن التجديد في السيرفر، فتصبح النافذة دقائق بدلاً من أيام، وقد شرحنا [تدفق Refresh Token](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-oauth-%D9%88-openid-connect-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#refresh) و[مدة صلاحية التوكنات وتدويرها](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-oauth-%D9%88-openid-connect-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#lifetimes) في مقال OAuth.
- **قائمة إلغاء Deny-List:** يحفظ السيرفر معرف `jti` للتوكنات الملغاة حتى وقت انتهائها، ويفحصها مع كل طلب، وهذا يعمل كما سوف نرى في التجربة، ولكن لاحظ أنك عدت إلى البحث في مخزن مع كل طلب، أي إلى الشيء الذي جاء JWT ليتجنبه، وإن كانت القائمة صغيرة لأنها تحتوي الملغى فقط. و[دليل OWASP لـ JWT](https://cheatsheetseries.owasp.org/cheatsheets/JSON%5FWeb%5FToken%5FCheat%5FSheet.html?ref=arabroot.io) يوصي ببناء القائمة على `jti` و`iss` وليس على التوكن كاملاً أو هاشه.
- **تغيير مفتاح التوقيع:** يبطل كل التوكنات مرة واحدة، وهو حل للطوارئ فقط، مثل تسرب المفتاح، لأنه يخرج كل المستخدمين.

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

- **الثقة بالحقل `alg` في الترويسة:** الترويسة يكتبها من صنع التوكن، فإذا كانت المكتبة تختار طريقة التحقق حسب `alg` فقد يرسل المخترق `"alg":"none"` أي توكناً دون توقيع، أو يرسل `HS256` لسيرفر يتوقع `RS256` فتستخدم المكتبة المفتاح العام المنشور كأنه سر HMAC، وهذا ما يسمى خلط الخوارزميات Algorithm Confusion. والحل أن يحدد التطبيق الخوارزميات المسموحة بنفسه، كما ينص [RFC 8725 لأفضل الممارسات الحالية مع JWT](https://datatracker.ietf.org/doc/html/rfc8725?ref=arabroot.io) الصادر في فبراير 2020.
- **سر ضعيف مع `HS256`:** من يحمل توكناً واحداً يستطيع أن يجرب ملايين الأسرار على جهازه دون أن يتصل بالسيرفر، فإذا كان السر كلمة مثل `secret` أو اسم الشركة فسوف يجده ويصدر ما يشاء من توكنات، لذلك لا تقم باستخدام كلمة يحفظها إنسان، وولد السر عشوائياً.
- **بيانات حساسة في المحتوى:** المحتوى مقروء لكل من يحمل التوكن، وهو يمر في سجلات السيرفرات وأدوات المراقبة، فلا تضع فيه رقم هوية أو هاتفاً أو أي سر.
- **عدم التحقق من `aud` و`iss` و`exp`:** التحقق من التوقيع وحده يقول إن التوكن صحيح، ولا يقول إنه صدر لك أو إنه ما زال صالحاً، فإذا كانت عندك عدة خدمات تثق بنفس المصدر فإن توكن الخدمة الأولى يفتح الثانية إذا لم تتحقق من `aud`.
- **توكن طويل العمر دون تجديد:** توكن صالح لشهر لأن «التجديد معقد» هو كلمة مرور ثانية لا يمكن إلغاؤها.

📌

في 31 مارس 2015 نشر الباحث Tim McLean في [مدونة Auth0](https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/?ref=arabroot.io) أنه وجد مكتبات JWT كثيرة تسمح بتجاوز التحقق من التوقيع، منها node-jsonwebtoken وpyjwt وphp-jwt، لأنها كانت تختار خوارزمية التحقق من الحقل `alg` الذي يكتبه المهاجم، فبعضها قبل توكنات `none` دون توقيع، وبعضها قبل توكن `HS256` موقعاً بالمفتاح العام لسيرفر يستخدم `RS256`. وسجلت ثغرة المكتبة jsonwebtoken لاحقاً برقم [CVE-2015-9235](https://github.com/advisories/GHSA-c7hr-j4mj-j2w6?ref=arabroot.io) بدرجة حرجة، وأغلقت في الإصدار 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`، كما شرحنا في قسم [أين تحفظ التوكنات](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-oauth-%D9%88-openid-connect-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#token-storage).

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

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

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

![مخطط من ثلاثة أعمدة، الأول ملف تعريف ارتباط HttpOnly لا يقرؤه السكربت المحقون ولكنه يرسل تلقائياً فيحتاج حماية من CSRF بالخاصية SameSite وتوكن CSRF وفحص Origin، والثاني متغير في ذاكرة JavaScript يضيع عند تحديث الصفحة ويستطيع السكربت المحقون أن يستخدمه ما دامت الصفحة مفتوحة، والثالث localStorage وsessionStorage يقرؤه أي سكربت فيرسل التوكن إلى سيرفر المخترق](https://arabroot.io/content/images/2026/10/sessions-cookies-jwt-06-storage.webp)

لا يوجد مكان آمن من XSS، وإنما مكان يقلل ما يأخذه المخترق

- **`localStorage` و`sessionStorage`:** أسهل مكان، وأي سكربت في الصفحة يقرؤه، فثغرة XSS واحدة، أو مكتبة JavaScript مخترقة تحملها الصفحة، تعني أن التوكن خرج إلى جهاز المخترق ويعمل من هناك حتى ينتهي، و`localStorage` لا ينتهي وحده، لذلك لا تقم بحفظ توكن طويل العمر فيه، وقد نصح [دليل OWASP لأمان HTML5](https://cheatsheetseries.owasp.org/cheatsheets/HTML5%5FSecurity%5FCheat%5FSheet.html?ref=arabroot.io) بعدم حفظ معرفات الجلسة فيه.
- **متغير في الذاكرة:** يضيع عند تحديث الصفحة، والسكربت المحقون لا يستطيع غالباً أن يقرأه إذا كان مخفياً داخل الكود، ولكنه يستطيع أن يرسل الطلبات باسم المستخدم ما دامت الصفحة مفتوحة، ويحتاج التطبيق طريقة ليحصل على توكن جديد بعد التحديث، وغالباً تكون ملف تعريف ارتباط.
- **ملف تعريف ارتباط `HttpOnly`:** لا يقرؤه أي سكربت، فلا يخرج من المتصفح حتى مع XSS، وإن كان السكربت المحقون يستطيع أن يرسل الطلبات من داخل الصفحة ما دامت مفتوحة. وثمن ذلك أن المتصفح يرسله تلقائياً، فيجب أن تحمي التطبيق من CSRF.

والحماية من CSRF ثلاث طبقات، وتفصيلها في [دليل OWASP لمنع CSRF](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site%5FRequest%5FForgery%5FPrevention%5FCheat%5FSheet.html?ref=arabroot.io):

1. **`SameSite=Lax` أو `Strict`:** لا يرسل المتصفح ملف الجلسة مع نموذج POST أو طلب خفي يبدأ من موقع آخر، وهذه أهم طبقة وأرخصها.
2. **توكن CSRF:** قيمة عشوائية يضعها السيرفر في الصفحة ويطلبها مع كل طلب يغير البيانات، والموقع الآخر لا يستطيع قراءتها، وأغلب الأطر تضيفه جاهزاً، فلا تقم بتعطيله.
3. **فحص `Origin`:** يرسل المتصفح مع الطلبات التي تغير البيانات الترويسة `Origin`، فيرفض السيرفر أي طلب من مصدر لا يعرفه. وتذكر أن CORS لا تمنع CSRF، لأن الطلب يصل وينفذ حتى لو منع المتصفح الصفحة من قراءة الرد، وقد شرحنا الفرق بين هذه الآليات في [مقارنة CORS وCSP وCSRF وSameSite](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-cors-%D9%88%D8%B3%D9%8A%D8%A7%D8%B3%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1-%D8%A7%D9%84%D9%88%D8%A7%D8%AD%D8%AF/#comparison).

أما XSS فلا يحميك منه مكان التخزين، وإنما ترميز المخرجات Output Encoding في القوالب، وسياسة أمان المحتوى Content-Security-Policy التي شرحناها في [دليل ترويسات الأمان](https://arabroot.io/articles/%D8%AA%D8%B1%D9%88%D9%8A%D8%B3%D8%A7%D8%AA-%D8%A7%D9%84%D8%A3%D9%85%D8%A7%D9%86-hsts-%D9%88-csp/#csp)، والسبب أن المخترق الذي يشغل كوده في صفحتك يستطيع أن يفعل أي شيء يفعله المستخدم، فالتخزين الصحيح يمنعه من أخذ الجلسة معه فقط.

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

في هذه التجربة نكتب تطبيق 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 للتوكنات:

```bash
mkdir lab-session && cd lab-session
npm init -y
npm pkg set type=module
npm install express@5.2.1 express-session@1.19.0 connect-redis@10.0.0 redis@6.3.0 jsonwebtoken@9.0.3
```

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

```bash
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`:

```javascript
// 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(', ')}`));
```

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

```javascript
// 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](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%83-%D8%AE%D9%84%D9%81-reverse-proxy/#express).
- `saveUninitialized: false` يعني أن الزائر لا يحصل على ملف جلسة حتى يكون هناك ما يحفظ، و`rolling: true` يعيد ضبط مدة الخمول مع كل طلب، أي أن `maxAge` هنا هي الـ Idle Timeout، أما `ABSOLUTE` فهي الانتهاء المطلق بعد 8 ساعات من الدخول.
- `req.session.regenerate` في مسار الدخول هي حماية تثبيت الجلسة، ونقلنا السلة يدوياً إلى الجلسة الجديدة حتى لا يخسرها الزائر.
- `req.session.destroy` تحذف الجلسة من المخزن، و`clearCookie` تطلب من المتصفح حذف ملفه.
- `VERIFY` يحدد الخوارزمية المسموحة والمصدر والجمهور، فالمكتبة لا تأخذ الخوارزمية من التوكن، وترفض أي توكن صدر لجمهور آخر.

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

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

```bash
BACKENDS=127.0.0.1:47811 node proxy.js
```

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

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

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

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

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

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

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

```text
naive: you are sara
naive: you are admin
```

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

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

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

```bash
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}')
```

```text
# 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 دقيقة. والآن نسجل الدخول بنفس وعاء ملفات تعريف الارتباط:

```bash
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
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…` والسلة بقيت، وهذا ما يقوله سجل التطبيق أيضاً. والآن نجرب ما كان سوف يفعله مخترق ثبت المعرف الأول في متصفح الضحية قبل أن تسجل الدخول:

```bash
curl -sk -b "__Host-sid=$OLD" https://127.0.0.1:47810/me
```

```text
app-a: not logged in
```

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

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

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

```text
app-a: not logged in
app-a: not logged in
```

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

```bash
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
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` هو الطريقة المعتادة لطلب حذف الملف من المتصفح. وسجل التطبيق يلخص ما حدث:

```text
[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 به:

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

```text
{"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` في المحتوى ونترك التوقيع كما هو:

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

```text
{"error":"JsonWebTokenError: invalid signature"}
```

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

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

```text
{"error":"JsonWebTokenError: jwt signature is required"}
```

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

```bash
curl -sk -H "Authorization: Bearer $TOKEN" https://127.0.0.1:47810/api/me; echo
```

```text
{"served_by":"app-a","sub":"sara","role":"user","expires_in":57}
```

التوكن ما زال يعمل، فلا يوجد في السيرفر شيء نحذفه، وهذا هو الفرق الذي رأيناه في [صورة الإلغاء](#jwt-revocation). وبعد أن تمر الدقيقة نعيد نفس الأمر:

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

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

```text
[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 يوزع بينهما:

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

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

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

```text
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 بكلمة مرور، ودون حفظ على القرص لأنها تجربة:

```bash
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 كما هو:

```bash
export REDIS_URL="redis://:$VALKEY_PASS@127.0.0.1:47819"
NAME=app-a PORT=47811 node app.js
NAME=app-b PORT=47812 node app.js
```

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

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

```text
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`، ومع ذلك انتقلت السلة إلى الجلسة الجديدة، لأن النسختين تقرآن من مكان واحد:

```text
[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 لنرى ما يحفظه التطبيق فعلاً:

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

```text
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 أيضاً، فتراها النسختان:

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

```text
{"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 والمجلد:

```bash
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](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%83-%D8%AE%D9%84%D9%81-reverse-proxy/).
- **يخرج المستخدم كل بضعة طلبات:** أكثر من نسخة من التطبيق والجلسات في الذاكرة، كما رأينا في الخطوة 4، أو أن لكل نسخة سراً مختلفاً لتوقيع ملف الجلسة.
- **يخرج الجميع بعد كل نشر Deploy:** الجلسات في ذاكرة التطبيق، أو أن سر الجلسة يولد عشوائياً عند كل تشغيل بدلاً من قراءته من متغير بيئة ثابت.
- **لا يرجع ملف الجلسة بعد العودة من مزود الدخول:** الخاصية `SameSite=Strict` على ملف يحتاج أن يرجع بعد تحويل من موقع آخر، وغالباً `Lax` هي القيمة الصحيحة.
- **ملف الجلسة لا يصل إلى ال API في نطاق آخر:** الطلب من نطاق مختلف لا يحمل ملفات تعريف الارتباط إلا مع `credentials: "include"` وإعداد CORS صحيح، كما في قسم [بيانات الدخول في CORS](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-cors-%D9%88%D8%B3%D9%8A%D8%A7%D8%B3%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1-%D8%A7%D9%84%D9%88%D8%A7%D8%AD%D8%AF/#credentials)، وإذا كان النطاق موقعاً آخر 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.

[![](https://arabroot.io/content/images/2026/10/oauth-oidc-cover-cover.webp)دليلشرح OAuth وOpenID Connect للمبتدئينلماذا لا يجب أن يعرف أي تطبيق كلمة مرورك، وكيف يحل OAuth ذلك بتوكنات محدودة، وكيف يضيف OpenID Connect الدخول الموحد.](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-oauth-%D9%88-openid-connect-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/security-headers-cover-cover.webp)دليلHSTS وCSP: ترويسات الأمان التي يحتاجها كل تطبيقما الذي تطلبه كل ترويسة أمان من المتصفح، ولماذا يصعب التراجع عن HSTS، وكيف تبني سياسة CSP لا تكسر تطبيقك.](https://arabroot.io/articles/%D8%AA%D8%B1%D9%88%D9%8A%D8%B3%D8%A7%D8%AA-%D8%A7%D9%84%D8%A3%D9%85%D8%A7%D9%86-hsts-%D9%88-csp/)[![](https://arabroot.io/content/images/2026/10/cors-cover-cover.webp)دليلCORS: لماذا يرفض المتصفح طلبك، وكيف تسمح به بأمانلماذا يمنع المتصفح صفحتك من قراءة رد ال API، وكيف تفتح CORS هذا الباب لمصادر محددة دون أن تفتحه لكل موقع.](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-cors-%D9%88%D8%B3%D9%8A%D8%A7%D8%B3%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1-%D8%A7%D9%84%D9%88%D8%A7%D8%AD%D8%AF/)[![](https://arabroot.io/content/images/2026/10/passkeys-cover-cover.webp)دليلالدخول دون كلمة مرور: شرح Passkeys وWebAuthnلماذا لا تحميك رموز SMS وTOTP من التصيد الحديث، وكيف تحل Passkeys المشكلة بمفتاح خاص لا يغادر جهازك.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AF%D8%AE%D9%88%D9%84-%D8%AF%D9%88%D9%86-%D9%83%D9%84%D9%85%D8%A9-%D9%85%D8%B1%D9%88%D8%B1-passkeys-%D9%88-fido2/)[![](https://arabroot.io/content/images/2026/10/behind-proxy-cover-cover.webp)دليلكيف يرى تطبيقك عنوان الزائر الحقيقي خلف ال Reverse Proxyإذا كان تطبيقك يرى عنوان ال Proxy بدلاً من الزائر أو يولد روابط http، فهذا هو السبب والإعداد الصحيح لإطار عملك.](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%83-%D8%AE%D9%84%D9%81-reverse-proxy/)

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

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