সরাসরি পর্বে যান
পর্ব ৪৫ · ৯৩-এর মধ্যে · মডিউল ৭

Session-ভিত্তিক login — cookie, store আর logout বাস্তবে

Session-Based Login — Cookies, Stores and Real Logout in Express and React

Taught in Bangla, this lesson builds session login in Express and React: a random id in the cookie and the record in a store, hashing passwords at registration, equal messages and timing at login, express-session with a real store, cookie attributes, subdomains and proxies, session fixation and regenerate, requireAuth with idle and absolute timeouts, logout from one or all devices with a real bug, and the React side with /me, protected routes and CORS.

এই পর্বে যা শিখবেন

  • Express 5-এ argon2 দিয়ে register আর login বানাতে, আর hash-এর খরচ মাপতে
  • express-session-কে PostgreSQL বা Redis store-এর সাথে জুড়তে, বা নিজে session row লিখতে
  • প্রতিটি cookie attribute আসল Set-Cookie আর আসল browser-এ যাচাই করতে
  • Regenerate, idle ও absolute timeout, logout, সব device থেকে logout আর device তালিকা বানাতে
  • React-এ /me, protected route, CORS, subdomain আর proxy-র পেছনের cookie সামলাতে

১ · চাবি browser-এ, খাতা server-এ

বাজারঘরে এতদিন যেকোনো ক্রেতা যেকোনো অর্ডার দেখতে পারতেন, কারণ server জানত না কে কথা বলছে। এই মডিউলে সেই ফাঁক বন্ধ হবে। তত্ত্ব আছে সাইবার নিরাপত্তা কোর্সে: Authentication-এর ভিত্তি, Password সংরক্ষণ আর Session আর JWT-এর নিরাপত্তা. সেখানে জেনেছেন কেন HTTP নিজে কিছু মনে রাখে না, আর কেন session id এলোমেলো হতে হয়। এখানে আমরা সেটি চালাব, মাপব আর ভাঙব।

হোটেলের রিসেপশনের কথা ভাবুন। পরিচয়পত্র দেখিয়ে আপনি একটি চাবি-কার্ড পেলেন। কার্ডে আপনার নাম লেখা নেই, শুধু একটি নম্বর। নাম, রুম আর চেক-আউটের সময় লেখা থাকে রিসেপশনের খাতায়। প্রতিবার দরজায় কার্ড ছোঁয়ালে হোটেল খাতা মিলিয়ে নেয়। চেক-আউটে খাতা থেকে নাম কাটলে কার্ডটি আপনার পকেটে থাকলেও আর দরজা খোলে না। Session হুবহু এমন: cookie হলো কার্ড, session store হলো খাতা।

বাজারঘরের login-এর পথ

The cookie holds only an id; identity lives in the store

Browser থেকে session store পর্যন্ত login-এর স্তর Browser Reverse proxy Express 5 PostgreSQL বা Redis cookie: শুধু একটি id, JS পড়তে পারে না TLS শেষ · X-Forwarded-Proto জোড়ে session → absolute যাচাই → requireAuth → route sid → userId, device, সময় · users table Cookie: sid HTTP, ভেতরের network প্রতি অনুরোধে একবার
ছবি ৪৫.১ — প্রতিটি স্তর শুধু নিজের কাজটুকু জানে। পরিচয়ের সত্য থাকে সবচেয়ে নিচের স্তরে।

এবার পুরো পথটি একটি আসল login দিয়ে দেখুন। নিচের মানগুলো আমাদের ল্যাবের run থেকে নেওয়া।

  1. BrowserPOST /loginemail, password
  2. Argon2verifyপ্রায় ২০ ms
  3. Storesession rowনতুন id
  4. CookieSet-Cookiebrowser জমা রাখে
অনুরোধএখনো কোনো cookie নেই
POST /api/login
content-type: application/json

{"email":"rahima@example.com",
 "password":"example-password-1"}
Server-এর ভেতরেলেখা password কোথাও জমা হয় না
SELECT id, name, password_hash FROM users
WHERE email = $1      -- rahima@example.com

argon2.verify(hash, password) → true
session tableসত্যিকারের run-এর একটি row, ছাঁটা
sid    F_JTu0KStBx-xA7SAbxI-i4A4s3tz8hE
sess   {"userId":"1",
        "createdAt":1793558582133,
        "lastSeen":1793558582133,
        "device":{"ua":"Safari on iPhone", …}}
expire 2026-11-01 19:13:03
উত্তরHTTP/1.1 200 OK · সত্যিকারের run
Set-Cookie: sid=s%3APVcYYpNBTkXTJYP9Ttcc
  DrO2DeE0S4WX.4jch8f6PIyS0K1BacNuX2F%2B
  DbdFcX8bPSNGph0nD1bE; Path=/;
  Expires=Sun, 01 Nov 2026 19:13:02 GMT;
  HttpOnly; SameSite=Lax

{"id":"1","name":"Rahima"}
পরের অনুরোধBrowser নিজে cookie জুড়ে দেয়
GET /api/me
Cookie: sid=s%3APVcY…(একই cookie)

session middleware: স্বাক্ষর মিলল, row পাওয়া গেল
req.session.userId = "1"
উত্তরHTTP/1.1 200 OK · সত্যিকারের run
{"id":"1","name":"Rahima",
 "email":"rahima@example.com"}

cookie না থাকলে:
HTTP/1.1 401 Unauthorized
{"error":"not_authenticated"}
  1. অনুরোধ। ফর্ম থেকে email আর password গেল। Production-এ এটি TLS-এর ভেতর দিয়ে যায়। Browser-এ এখনো কোনো cookie নেই।
  2. যাচাই। Server email ধরে row আনল, আর argon2 password মেলাল। লেখা password ডেটাবেসে নেই, আছে শুধু password hash.
  3. Row তৈরি। নতুন id-তে একটি row বসল। পরিচয় আর সময়ের তথ্য এখানেই থাকে। ছবিতে অন্য device-এর row, কারণ run-এ তিনটি device ছিল।
  4. Cookie গেল। sid-এর মান হলো s:, id, একটি বিন্দু আর স্বাক্ষর। %3A হলো :-এর URL-রূপ। ব্যবহারকারীর নাম বা email এখানে নেই।
  5. পরের অনুরোধ। Browser নিজে থেকে cookie জুড়ে দিল। Server স্বাক্ষর মিলিয়ে id বের করল, তারপর store থেকে row আনল।
  6. উত্তর। userId থাকায় /me নাম আর email দিল। Cookie না থাকলে একই route 401 দেয়।

এখন একই ধারণা কোনো package ছাড়া চালান। শুধু node:http আর node:crypto, ফলে প্রতিটি লাইন দেখা যায়।

// শেখার মডেল: node:http-এ login, cookie আর /me
const http = require('node:http'), crypto = require('node:crypto');
// scrypt-এর cost ইচ্ছা করে খুব কম (N=2^10), যাতে ব্লকটি দ্রুত শেষ হয়।
const hash = (pw, salt) => crypto.scryptSync(pw, salt, 32, { N: 1024, r: 8, p: 1 });
const salt = crypto.randomBytes(16);
const stored = hash('example-password-1', salt);   // Rahima-র hash
const sessions = new Map();                        // sid -> নাম
const server = http.createServer(async (req, res) => {
  const send = (code, body, head = {}) => {
    res.writeHead(code, head); res.end(JSON.stringify(body)); };
  if (req.url === '/login') {
    let password = ''; for await (const c of req) password += c;
    if (!crypto.timingSafeEqual(hash(password, salt), stored))
      return send(401, { error: 'invalid_credentials' });
    const sid = crypto.randomBytes(32).toString('base64url');
    sessions.set(sid, 'Rahima');
    return send(200, { ok: true }, {
      'set-cookie': `sid=${sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age=1800` });
  }
  const sid = /(?:^|; )sid=([^;]+)/.exec(req.headers.cookie ?? '')?.[1];
  const name = sessions.get(sid);
  send(name ? 200 : 401, name ? { name } : { error: 'not_authenticated' });
});
server.listen(0, '127.0.0.1', async () => {
  const url = `http://127.0.0.1:${server.address().port}`;
  const me = (h) => fetch(url + '/me', { headers: h }).then(async (r) => r.status + ' ' + await r.text());
  const login = (body) => fetch(url + '/login', { method: 'POST', body });
  console.log('no cookie  ', await me({}));
  console.log('wrong pass ', (await login('wrong-password-0')).status);
  const r = await login('example-password-1');
  const raw = r.headers.get('set-cookie');
  console.log('login      ', r.status, raw.replace(/=[^;]{43}/, '=<43 chars>'));
  console.log('with cookie', await me({ cookie: raw.split(';')[0] }));
  server.close();
});

আউটপুট: no cookie 401, wrong pass 401, login 200 sid=<43 chars>; Path=/; HttpOnly; SameSite=Lax; Max-Age=1800, তারপর with cookie 200 {"name":"Rahima"}. ৩২ byte এলোমেলো মান base64url-এ ৪৩ অক্ষর হয়। প্রতিবার মান বদলায়, তাই ব্লকটি শুধু দৈর্ঘ্য ছাপে। ব্লকের scrypt cost ইচ্ছা করে খুব কম, কারণ এটি ভাগ করা runner-এ চলে আর এক সেকেন্ডের অনেক আগে শেষ হতে হয়। আসল সাইটে এই মান চলবে না।

২ · Register: password নয়, hash জমা রাখা

এবার আসল project. ল্যাবে আছে Express 5.3.0, argon2 0.45.1, Zod 4.6.7 আর PostgreSQL 18.6 (embedded-postgres-এর 18.6.0-beta.19, npm-এ এই package-এর latest tag-ই beta). users table সরল রাখতে pg সরাসরি ব্যবহার করেছি। Prisma ব্যবহার করলে (পর্ব ৩৯) শুধু query-র রূপ বদলাবে, নকশা নয়।

const Register = z.object({
  email: z.email().toLowerCase(),
  name: z.string().min(1).max(80),
  password: z.string().min(12).max(128),
});
const HASH = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 };

app.post('/api/register', async (req, res) => {
  const p = Register.safeParse(req.body);
  if (!p.success) return res.status(400).json({ error: 'invalid_input' });
  const hash = await argon2.hash(p.data.password, HASH);
  const r = await pool.query(
    `INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3)
     ON CONFLICT (email) DO NOTHING RETURNING id`,
    [p.data.email, p.data.name, hash]);
  if (r.rowCount === 0) return res.status(409).json({ error: 'email_taken' });
  res.status(201).json({ id: r.rows[0].id });
});

চারটি সিদ্ধান্ত খেয়াল করুন। Email ছোট হাতের করা হয়েছে, তাই Rahima@ আর rahima@ দুটি account হয় না। Password-এর ওপরের সীমা আছে, যাতে কেউ খুব লম্বা ইনপুট দিয়ে CPU আটকাতে না পারে। Query parameterized (Injection প্রতিরোধ). আর ON CONFLICT দুটি একসাথে আসা অনুরোধেও একটিই row রাখে। 409 অবশ্য জানিয়ে দেয় email-টি আছে। Signup-এ এটি কীভাবে এড়ানো যায়, সেটি পর্ব ৪৯-এর (Account-এর কাজ) বিষয়।

সত্যিকারের run · Node 24.15 · argon2 0.45.1 · একটি local কম্পিউটারে, ৯ বারের মধ্যমা · এটি সাধারণ ফল নয়

default  $argon2id$v=19$m=65536,p=4,t=3$suvvG1lLUO562tms3Bb3/Q$rDnkDGzN1NN7…
19 MiB   $argon2id$v=19$m=19456,p=1,t=2$yi/WyOBlVLnLQPTrOR1Rig$7iJRVS7fNgay…

hash default ms    36.4
hash 19MiB ms      19.3
verify 19MiB ms    19.2
verify wrong ms    19.2
scrypt N=2^17 ms  198.3
scrypt N=2^10 ms    1.41

Hash-এর লেখাটিই নিজের পরিচয় বহন করে: algorithm, version, মেমরি (m, KiB-এ), p, t, salt আর ফল। তাই পরে cost বাড়ালেও পুরোনো hash মিলবে। ১৯ MiB, t=2, p=1 হলো Password সংরক্ষণ পর্বের OWASP ন্যূনতম মান। প্যাকেজের ডিফল্ট আরও ভারী। ঠিক কত সময় চলবে, তা আপনার server-এ মাপতে হবে। সংখ্যাটি এই যন্ত্রের, দুর্বল VPS-এ কয়েক গুণ হতে পারে।

scrypt ব্যবহার করলে একটি ফাঁদ: node:crypto-র scrypt-এ N=2^17 দিলে ডিফল্ট maxmem-এ সরাসরি error আসে: ERR_CRYPTO_INVALID_SCRYPT_PARAMS ... memory limit exceeded. সমাধান maxmem: 256 * 1024 * 1024. আমাদের Node 24.15-এ crypto.argon2 ফাংশনও আছে, তবে Wandbox-এর Node 20-এ নেই। কোন Node-এ আছে, তা নিজের version-এর docs-এ দেখে নিন।

হিসাব: login-এর ঢেউ server কতটা নিতে পারে

Argon2-এর কাজ ইচ্ছা করেই ভারী, আর এই package চলে Node-এর thread pool-এ, ডিফল্টে ৪টি thread. একই যন্ত্রে একসাথে verify করে মাপা হলো:

একসাথে verifyমোট সময়প্রতি সেকেন্ডে
১২০ ms৫০
৪৩০ ms১৩৩
৮৪৭ ms১৭০
৩২১৯৩ ms১৬৬

সত্যিকারের run · একটি local কম্পিউটারে একবার · প্রতি সেকেন্ডের হিসাব = সংখ্যা ÷ মোট সময়

৩২ ÷ ০.১৯৩ s ≈ ১৬৬. অর্থাৎ এই যন্ত্রে সিলিং প্রায় ১৭০ login প্রতি সেকেন্ডে। ধরুন ঈদের অফারে ৩০০ login/s এল। তখন প্রতি সেকেন্ডে ৩০০ − ১৬৬ = ১৩৪টি অনুরোধ সারিতে জমবে, আর ১০ সেকেন্ডে ১,৩৪০টি। মেমরিও সীমিত: ৪টি thread × ১৯ MiB = ৭৬ MiB, কারণ একসাথে ৪টির বেশি চলে না। m তিন গুণ করলে সময় আর মেমরি দুটিই বাড়ে। এজন্যই login route-এ rate limit লাগে, যা পর্ব ৪৯-এ আসবে।

৩ · Login: একই বার্তা, একই সময়

ভুল email আর ভুল password-এর আলাদা বার্তা দিলে login ফর্মটিই বলে দেয় কোন email-এ account আছে। একে বলে user enumeration, তত্ত্ব আছে Authentication-এর ভিত্তি পর্বে। বার্তা এক করা সহজ। কিন্তু সময়ও বার্তা বহন করে: email না থাকলে argon2 চলেই না, তাই উত্তর আগে আসে।

const DUMMY = await argon2.hash('example-dummy-password', HASH);

app.post('/api/login', async (req, res) => {
  const p = Login.safeParse(req.body);
  if (!p.success) return res.status(400).json({ error: 'invalid_input' });
  const { rows } = await pool.query(
    'SELECT id, name, password_hash FROM users WHERE email = $1', [p.data.email]);
  const user = rows[0];
  const ok = user
    ? await argon2.verify(user.password_hash, p.data.password)
    : (await argon2.verify(DUMMY, p.data.password), false);
  if (!ok) return res.status(401).json({ error: 'invalid_credentials' });
  // session-এর অংশ ৬-এ
});

সত্যিকারের run · দুই ক্ষেত্রেই HTTP 401 আর হুবহু একই body · ৩১ বারের মধ্যমা · একটি local কম্পিউটারে

{"error":"invalid_credentials"}

dummy hash ছাড়া   wrong password 21.0 ms   unknown email  0.8 ms
dummy hash সহ      wrong password 22.0 ms   unknown email 21.4 ms

পার্থক্য ২০ ms থেকে এক ms-এর নিচে নেমেছে। ETag পর্যন্ত দুটি উত্তরে একই ছিল, কারণ body এক। একটি কথা সৎভাবে বলা দরকার: এটি পার্থক্য কমায়, পুরো মুছে দেয় না। Network-এর ওঠানামা আর rate limit মিলে এটি কার্যকর হয়।

৪ · express-session আর আসল store

express-session 1.19.0 একটি middleware. প্রতিটি অনুরোধে সে cookie পড়ে, স্বাক্ষর মেলায়, store থেকে row এনে req.session বসায়, আর উত্তরের শেষে পরিবর্তন থাকলে লিখে দেয়। Store-টি আলাদা package: connect-pg-simple 10.0.0 বা connect-redis 10.0.0 (Node 22 বা নতুন, redis 5 বা নতুন peer). সংখ্যাগুলো লেখার সময়ের।

import session from 'express-session';
import connectPg from 'connect-pg-simple';

const PgStore = connectPg(session);
app.use(express.json({ limit: '10kb' }));
app.use(session({
  name: '__Host-sid',
  secret: [process.env.SESSION_SECRET, process.env.SESSION_SECRET_OLD],
  store: new PgStore({ pool, tableName: 'session',
    createTableIfMissing: true, pruneSessionInterval: 60 }),
  resave: false,
  saveUninitialized: false,
  rolling: true,
  cookie: { httpOnly: true, secure: true, sameSite: 'lax',
            path: '/', maxAge: 30 * 60 * 1000 },
}));
Optionকেন এই মান
secret (array)প্রথমটি দিয়ে স্বাক্ষর হয়, বাকিগুলো দিয়ে পুরোনো cookie যাচাই হয়। তাই secret বদলালে সবাই logout হন না।
saveUninitialized: falseফাঁকা session-এর row বা cookie হবে না। Crawler-এর প্রতিটি visit-এ row জমবে না।
resave: falseনা বদলালে পুরো row আবার লেখা হবে না। একসাথে আসা দুটি অনুরোধ একে অন্যের লেখা মুছবে না।
rolling: trueপ্রতি উত্তরে cookie-র মেয়াদ নতুন করে যায়। অংশ ৭-এর sliding expiry এটিই।
pruneSessionIntervalমেয়াদ শেষের row কত সেকেন্ড পরপর মুছবে। ডিফল্ট ১৫ মিনিট।

ডিফল্ট session id ২৪ byte, মানে ১৯২ bit, আর base64url-এ ৩২ অক্ষর। আমরা স্বাক্ষরও পরীক্ষা করেছি। একটি cookie-র শেষ তিন অক্ষর বদলালে /me 401 দিল। স্বাক্ষরহীন বানানো id-তেও 401 এল, আর উত্তরে কোনো Set-Cookie ছিল না।

Store-এর ভেতরে কী আছে

সত্যিকারের run · Redis 8.10.3 · connect-redis 10.0.0 · একটি login · node-redis দিয়ে পড়া, উত্তর ছাঁটা

> GET sess:<sid>
{"cookie":{"originalMaxAge":1800000,"expires":"2026-11-01T19:10:16.185Z",
"secure":false,"httpOnly":true,"path":"/","sameSite":"lax"},"userId":"6", …
> TTL sess:<sid>             1800
(২ সেকেন্ড পরে) TTL          1798
(একটি অনুরোধের পরে) TTL      1800
(logout-এর পরে) EXISTS       0

Redis-এ TTL নিজেই row সরায়। PostgreSQL-এ expire column আছে, পড়ার সময় মেয়াদ মিলিয়ে দেখা হয়, আর পুরোনো row মোছে prune কাজ। Store না দিলে কী হয়? NODE_ENV=production-এ প্যাকেজ নিজেই সতর্ক করে:

সত্যিকারের run · store ছাড়া

Warning: connect.session() MemoryStore is not
designed for a production environment, as it will leak
memory, and will not scale past a single process.

অর্থাৎ server restart হলে সবাই logout, আর দুটি process থাকলে অর্ধেক অনুরোধে session নেই। এবার তিনটি store-এর খরচ দেখুন। Route-টি শুধু requireAuth পার হয়, কোনো query নেই।

Storep50p99req/sব্যবহারকারী ধরে খোঁজা
MemoryStore০.২৬ ms০.৮৩ ms৩,৩৭৭নেই; production-এর জন্য নয়
Redis 8.10.3০.৪৬ ms০.৯০ ms২,০২১আলাদা set রাখতে হয়
PostgreSQL 18.6০.৬৫ ms১.৫৪ ms১,৪৪৫SQL-এ সরাসরি

সত্যিকারের run · একই যন্ত্রে, network নেই · ৩,০০০ অনুরোধ, একটির পর একটি · এটি সাধারণ ফল নয়

Redis এখানে প্রায় ০.২ ms এগিয়ে। বাজারঘরের মতো সাইটে পাতার মোট সময়ের তুলনায় এটি ছোট, আর PostgreSQL আগে থেকেই আছে, তাই আমরা PostgreSQL দিয়ে শুরু করছি। ট্রাফিক বাড়লে বা Redis আগেই থাকলে (পর্ব ৪৪) Redis যুক্তিসঙ্গত। প্রকৌশলীরা দুই পথই নেন, ভাবনার প্রশ্নে এর বিনিময়গুলো আছে।

হাতে লেখা session row

একটি package ছাড়াও পথ আছে, আর পথটি ছোট। Cookie-র token এলোমেলো ৩২ byte, ডেটাবেসে রাখা হয় শুধু তার SHA-256. Store ফাঁস হলেও সেখান থেকে চালু cookie মেলে না। Token পুরোপুরি এলোমেলো, তাই এখানে argon2 লাগে না, দ্রুত hash-ই যথেষ্ট।

const sha = (t) => crypto.createHash('sha256').update(t).digest();

async function createSession(res, userId, ua) {
  const token = crypto.randomBytes(32).toString('base64url');
  await pool.query(
    `INSERT INTO app_session (token_hash, user_id, expires_at, user_agent)
     VALUES ($1, $2, now() + interval '7 days', $3)`, [sha(token), userId, ua]);
  res.cookie('__Host-sid', token, COOKIE);
}

// প্রতি অনুরোধে: খুঁজুন, আর last_seen লিখুন সর্বোচ্চ মিনিটে একবার
const { rows } = await pool.query(
  `SELECT user_id FROM app_session WHERE token_hash = $1 AND expires_at > now()
     AND last_seen > now() - interval '30 minutes'`, [sha(token)]);
await pool.query(`UPDATE app_session SET last_seen = now()
  WHERE token_hash = $1 AND last_seen < now() - interval '60 seconds'`, [sha(token)]);

সত্যিকারের run · একই PostgreSQL · hash ছাঁটা

Set-Cookie: __Host-sid=1KDw6GclKJERwrgffdeOSJa4fmH1I52jK1l7kyCu0Cg; Max-Age=1800;
  Path=/; Expires=Sun, 01 Nov 2026 19:05:32 GMT; HttpOnly; Secure; SameSite=Lax
cookie value chars 43
row: token_hash aae6096c4e48544a…  user_id 3  ttl 7 days
100 /me, last_seen changed: false
logout 204   /me 401   rows 0

লাভ: id নতুন হয় শুধু login-এ, তাই অংশ ৬-এর ফাঁদই নেই, আর user_id column-এ index থাকায় device তালিকা সহজ। দাম: অতিথির কার্ট, cookie পড়া আর sign-এর কাজ নিজের কাঁধে। একশোটি /me-এ একটিও UPDATE হয়নি, কারণ শর্তটি মিনিটে একবারের বেশি মেলে না।

৫ · Cookie-র attribute, subdomain আর proxy

প্রতিটি attribute কেন লাগে, সেই তত্ত্ব আছে Session আর JWT-এর নিরাপত্তা পর্বের অংশ ৩-এ। এখানে দেখা যাক এগুলো আসল header-এ কেমন দেখায়।

সত্যিকারের run · প্রথমটি express-session, দ্বিতীয়টি res.cookie

Set-Cookie: __Host-sid=s%3A0jgff4feiRoQ7k92F29MKzlumyFQsGTS.aisMAig0DVyOExmsREfTY…;
  Path=/; Expires=Sun, 01 Nov 2026 19:05:06 GMT; HttpOnly; Secure; SameSite=Lax

Set-Cookie: __Host-sid=1KDw6GclKJERwrgffdeOSJa4fmH1I52jK1l7kyCu0Cg; Max-Age=1800;
  Path=/; Expires=Sun, 01 Nov 2026 19:05:32 GMT; HttpOnly; Secure; SameSite=Lax
Attributeকী করেবাজারঘরে
HttpOnlyJavaScript cookie পড়তে পারে নাসবসময়; document.cookie ফাঁকা দেখাবে
Secureশুধু HTTPS-এ যায়Production-এ সবসময়
SameSiteঅন্য site থেকে আসা অনুরোধে পাঠাবে কি নাLax
Pathকোন path-এ পাঠাবে/; নিরাপত্তার সীমা নয়
Max-Age, ExpiresBrowser কতক্ষণ রাখবে৩০ মিনিট; দুটি থাকলে Max-Age জেতে
DomainSubdomain-এও পাঠাবেনা দেওয়াই ভালো
__Host- নামBrowser নিজে নিয়ম চাপায়Session cookie-তে

লক্ষ করুন, express-session শুধু Expires লেখে, আর Express-এর res.cookie দুটোই লেখে। ফল একই। Logout-এর res.clearCookie-তে একই Path, Secure আর SameSite দিতে হয়, নয়তো browser সেটিকে অন্য cookie ভেবে মোছে না।

Browser আসলে কী মানে: ১২টি cookie-র পরীক্ষা

একটি HTTPS server থেকে একসাথে ১২টি Set-Cookie পাঠিয়ে দেখলাম Edge কোনগুলো রাখে। ঠিকানাগুলো local পরীক্ষার, 127.0.0.1-এ মেলানো, certificate স্ব-স্বাক্ষরিত। Server ছিল api.bazarghar.test.

Set-Cookie (ছাঁটা)Edgeকারণ
__Host-ok=1; Path=/; Secureরাখলসব শর্ত মিলেছে, host-only
__Host-domain=1; Domain=bazarghar.test; …ফেলল__Host--এ Domain চলে না
__Host-path=1; Path=/account; SecureফেললPath / হতে হবে
__Host-noseq=1; Path=/ফেললSecure নেই
__Secure-sub=1; Domain=bazarghar.test; …রাখল__Secure- শুধু Secure চায়
foreign=1; Domain=other.test; …ফেললনিজের domain নয়
tld=1; Domain=test; …ফেললপুরো public suffix-এ cookie নয়

সত্যিকারের run · Edge 156 · Playwright · বাকি ৫টি cookie (host_only, shared, lax, strict, none) সবই রেখেছে

কে পায়: subdomain আর অন্য site

এবার দেখা যাক প্রতিটি পক্ষ কী দেখে। www আর api আলাদা origin, কিন্তু একই site. other.test অন্য site.

সত্যিকারের run · Edge 156 · server-এ পৌঁছানো Cookie header · প্রতিটি মান =1, এখানে বাদ

api নিজে            host_only shared __Host-ok __Secure-sub lax strict none
www খোলার সময়       shared __Secure-sub
www থেকে fetch api   host_only shared __Host-ok __Secure-sub lax strict none
other.test fetch api none
other.test লিংক ক্লিক host_only shared __Host-ok __Secure-sub lax none

এই পাঁচ লাইনেই পুরো নিয়মটি আছে। Domain দিলে তবেই www cookie পায়। www থেকে api-তে fetch same-site, তাই Strict-ও গেছে। অন্য site-এর fetch-এ Lax যায়নি, যা CSRF-এর একটি স্তর (CSRF আর cookie; পুরো তালিকা পর্ব ৫০-এ)। অন্য site-এর লিংকে ক্লিক করলে Lax যায়, Strict যায় না। তাই Strict দিলে অন্য সাইটের লিংক থেকে এসে ক্রেতা নিজেকে logout দেখবেন। SameSite=None এই run-এ গেছে, কিন্তু browser-গুলোর তৃতীয়-পক্ষ cookie নীতি বদলাচ্ছে, তাই এর ওপর ভর করবেন না।

মূল সিদ্ধান্ত

Cookie যত কম জায়গায় যায়, তত ভালো। Domain=bazarghar.example দিলে blog, status বা ভুলে পড়ে থাকা যেকোনো subdomain session cookie পায়। সবচেয়ে সরল নকশা: React আর API একই host-এ, proxy-র /api path-এ, আর cookie __Host-sid. তখন CORS-ও লাগে না।

Proxy-র পেছনে: cookie আসে না কেন

Production-এ TLS শেষ হয় reverse proxy-তে (TLS আর HTTPS), আর Express পায় সাধারণ HTTP. তখন secure: true থাকায় express-session cookie পাঠায়ই না, কারণ সে ভাবে সংযোগ নিরাপদ নয়। Login 200 দেয়, অথচ পরের অনুরোধে ব্যবহারকারী অচেনা। এটি সত্যিকারের একটি কঠিন ভুল, কারণ কোনো error আসে না।

সত্যিকারের run · Express 5.3.0 · nginx-এর অনুকরণে ছোট Node proxy, যা X-Forwarded-Proto: https জোড়ে · trust proxy তিনভাবে

-- trust proxy = off
via proxy  200 set-cookie: (none)
           req.secure false   proto http

-- trust proxy = 1
via proxy  200 set-cookie: __Host-sid=s%3A0jgff4fe…; Secure; SameSite=Lax
           req.secure true    proto https
forged X-Forwarded-For: 203.0.113.9  →  req.ip 127.0.0.1

-- trust proxy = true
via proxy  200 set-cookie: __Host-sid=s%3Ak4io8QqV…; Secure; SameSite=Lax
forged X-Forwarded-For: 203.0.113.9  →  req.ip 203.0.113.9

app.set('trust proxy', 1) মানে “ঠিক একটি proxy-কে বিশ্বাস করো”। true দিলে cookie ঠিক আসে, কিন্তু ব্যবহারকারীর পাঠানো X-Forwarded-For-ও বিশ্বাস করা হয়, ফলে log-এর IP আর IP-ভিত্তিক rate limit ভুল হয়। 1 দিলেও app-এর port সরাসরি খোলা থাকলে X-Forwarded-Proto বানিয়ে পাঠানো যায়, আমাদের run-এ সরাসরি অনুরোধে secure: true এসেছে। তাই app-এর port শুধু ভেতরের network-এ রাখুন। trust proxy মান গুনে বসান: সামনে CDN আর nginx থাকলে ২।

আরও গভীরে: Cookie-র মূল নিয়ম RFC 6265 (২০১১), আর SameSite ও prefix এসেছে তার হালনাগাদ কাজ RFC 6265bis থেকে। Chromium-ভিত্তিক browser ২০২০ সালে SameSite না থাকলে Lax ধরা শুরু করে, আমাদের run-এও shared-এর মান Lax দেখিয়েছে। “Site” ঠিক হয় scheme আর registrable domain দিয়ে, যা Public Suffix List থেকে আসে। Port এতে ধরা হয় না, তাই localhost:5173 আর localhost:3000 একই site.

৬ · Session fixation আর regenerate

বাজারঘরে অতিথিও কার্টে চাল তুলতে পারেন, তাই login-এর আগেই session থাকে। বিপদ এখানেই। Session fixation-এ চুরি হয় না: কোনো কারণে browser-এ আগে থেকে এমন একটি id বসে আছে, যা অন্য কেউ জানেন। Login-এর পরে server যদি সেই id-কেই “লগইন করা” বানায়, তবে জানা id-টি এখন পুরো account. ধারণাটির নাম Mitja Kolšek-এর ২০০২ সালের একটি গবেষণাপত্র থেকে প্রচলিত। প্রতিরক্ষা এক লাইনের: পরিচয় বদলালে id বদলান।

অতিথি হিসেবে হোটেলের লবিতে বসার একটি সাধারণ টোকেন পেলেন। রুম বুক করার পরে হোটেল যদি ওই টোকেনকেই রুমের চাবি বানায়, তবে যিনি আগে টোকেনের নম্বর দেখেছেন, তিনিও রুমে ঢুকতে পারবেন। তাই ভালো হোটেল পুরোনো টোকেন বাতিল করে নতুন কার্ড দেয়।

import { promisify } from 'node:util';
const regenerate = (req) => promisify(req.session.regenerate).call(req.session);
const saveSession = (req) => promisify(req.session.save).call(req.session);

// password মিলে যাওয়ার পরে
const cart = req.session.cart;          // অতিথির কার্ট আগে তুলে রাখা
await regenerate(req);                  // পুরোনো row মুছল, নতুন id
const now = Date.now();
Object.assign(req.session, {
  userId: String(user.id), createdAt: now, lastSeen: now, cart,
  device: { id: crypto.randomUUID(), ua: String(req.get('user-agent') ?? '').slice(0, 120) },
});
await saveSession(req);                 // উত্তরের আগেই row লেখা শেষ
res.json({ id: user.id, name: user.name });
1const cart = req.session.cart;2await regenerate(req);3Object.assign(req.session, {...});4await saveSession(req);5res.json({ id, name });
Browser আর store
cookie
row
userId
cart
  1. অতিথি। কার্টে চাল তোলায় hgT1… id-তে একটি row হলো। এতে userId নেই।
  2. Password মিলল। এখনো id বদলায়নি। কার্টটি একটি ভ্যারিয়েবলে তুলে রাখা হলো, নয়তো হারাবে।
  3. Regenerate. Store থেকে hgT1… row মুছল, আর req.session পেল একটি নতুন id. Browser এখনো পুরোনোটি জানে।
  4. পরিচয় বসল। নতুন session-এ userId, সময়, device আর কার্ট বসল। এখনো মেমরিতে, store-এ লেখা হয়নি।
  5. Save আর Set-Cookie. Qp6B… row লেখা হলো, আর উত্তরে নতুন cookie গেল। save-এর অপেক্ষা না করলে পরের অনুরোধ row না-ও পেতে পারে।
  6. পুরোনো id অকেজো। কেউ hgT1… জানলেও /me এখন 401, কারণ row-ই নেই। Fixation থামল।

নিচের ব্লকে একই কোড দুইবার চলে, একবার regenerate ছাড়া।

// শেখার মডেল: login-এ session id বদলানোর ফল
const http = require('node:http'), crypto = require('node:crypto');
async function run(regenerate) {
  const sessions = new Map();
  const newId = () => crypto.randomBytes(16).toString('hex');
  const server = http.createServer((req, res) => {
    const sid = /sid=(\w+)/.exec(req.headers.cookie ?? '')?.[1];
    const s = sessions.get(sid);
    const give = (id, body) => {
      res.setHeader('set-cookie', `sid=${id}; Path=/; HttpOnly`); res.end(body); };
    if (req.url === '/guest') {                // অতিথি session: কার্টের জন্য
      const id = newId();
      sessions.set(id, { cart: ['CHAL-5KG'] });
      return give(id, 'guest');
    }
    if (req.url === '/login') {                // password যাচাই হয়ে গেছে ধরুন
      let id = sid;
      if (regenerate) { sessions.delete(sid); id = newId(); }
      sessions.set(id, { ...s, user: 'rahima' });
      return give(id, 'ok');
    }
    res.statusCode = s?.user ? 200 : 401;      // /me
    res.end(s?.user ? JSON.stringify(s) : 'not_authenticated');
  });
  await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
  const base = `http://127.0.0.1:${server.address().port}`;
  const get = (p, cookie) => fetch(base + p, { headers: cookie ? { cookie } : {} });
  const oldC = (await get('/guest')).headers.get('set-cookie').split(';')[0];
  const newC = (await get('/login', oldC)).headers.get('set-cookie').split(';')[0];
  const before = await get('/me', oldC), after = await get('/me', newC);
  console.log(`regenerate=${regenerate}  id changed: ${oldC !== newC}`);
  console.log(`  old id /me ${before.status}   new id /me ${after.status} ${await after.text()}`);
  server.close();
}
run(false).then(() => run(true));

আউটপুট: regenerate=false id changed: false-এ old id /me 200, অর্থাৎ অতিথির id দিয়েই account খুলছে। regenerate=true id changed: true-এ old id /me 401 আর new id /me 200 {"cart":["CHAL-5KG"],"user":"rahima"}. id এলোমেলো, কিন্তু আউটপুট প্রতিবার একই থাকে, কারণ ব্লক id ছাপে না।

আসল express-session আর PostgreSQL-এও ফল একই। তিনটি run পাশাপাশি দেখুন:

সত্যিকারের run · express-session 1.19.0 · connect-pg-simple 10.0.0

regenerate ছাড়া
  guest sid 9ZF04_-qm11JjTyx4UQ18YFX7755VrvW
  after login sid 9ZF04_-qm11JjTyx4UQ18YFX7755VrvW SAME
  guest cookie /me 200
  cart {"cart":["CHAL-5KG"]}

regenerate, কার্ট তুলে রেখে
  guest sid hgT1No5Yw5KhENbfx8OosRd36HGW0UOT
  after login sid Qp6B_OoQ80ZmqRlzI_H7yjODDjFMbSbj NEW
  rows [ { sid: 'Qp6B_OoQ80ZmqRlzI_H7yjODDjFMbSbj', uid: '6' } ]
  guest cookie /me 401
  cart {"cart":["CHAL-5KG"]}

regenerate, কার্ট না তুলে
  after login sid blns27uyDMODZsAz4fRq1TYM432oszqB NEW
  cart {"cart":[]}

তৃতীয় run-টি বাস্তবের সবচেয়ে সাধারণ অভিযোগ: “login করলেই কার্ট খালি হয়ে যায়।” regenerate সব ডেটা ফেলে দেয়, যা রাখতে চান তা হাতে তুলে নিতে হয়। একই নিয়ম password বদলের সময়ও খাটে, কারণ সেখানেও পরিচয়ের মান বদলায় (পর্ব ৪৯).

৭ · requireAuth, দুই timeout আর sliding expiry

পর্ব ২৬-এ requireAuth-এর ছাঁচ দেখেছেন। এখন সেটি আসল session পড়ে। Route-level, অথবা privateRouter.use(requireAuth) দিয়ে পুরো router-এ বসান। “কার কী করার অনুমতি” পর্ব ৪৮-এর বিষয়, এখানে শুধু “ইনি কে”।

function requireAuth(req, res, next) {
  if (!req.session.userId) return res.status(401).json({ error: 'not_authenticated' });
  next();
}

function enforceAbsolute(req, res, next) {
  const s = req.session;
  if (!s.userId) return next();
  const now = Date.now();
  if (now - s.createdAt <= ABS_MS) {
    if (now - s.lastSeen > 60_000) s.lastSeen = now;
    return next();
  }
  s.destroy((err) => {
    if (err) return next(err);
    res.clearCookie(NAME, cookieOpts);
    res.status(401).json({ error: 'session_expired' });
  });
}
app.use(enforceAbsolute);   // session-এর ঠিক পরে

app.get('/api/me', requireAuth, async (req, res) => {
  const { rows } = await pool.query(
    'SELECT id, name, email FROM users WHERE id = $1', [req.session.userId]);
  res.json(rows[0]);
});

দুই ধরনের সময়সীমা আলাদা কাজ করে। Idle timeout বলে: এত সময় কোনো অনুরোধ না এলে শেষ। Absolute timeout বলে: যত ব্যস্তই থাকুন, login-এর এত পরে আবার প্রমাণ দিন। প্রথমটি sliding expiry: প্রতি অনুরোধে সময় আবার গোনা শুরু হয়। express-session-এ rolling আর maxAge মিলে এটি হয়ে যায়। Absolute timeout প্যাকেজে নেই, তাই createdAt দিয়ে নিজে লিখতে হয়। Cookie-র মেয়াদকে ভরসা করবেন না, কারণ browser-এর সময় ব্যবহারকারীর হাতে। সীমা টানে server.

দুই ক্রেতা, দুই সময়সীমা

Idle 30 min, absolute 120 min, values from the block below

Idle আর absolute timeout-এর সময়রেখা ব্যস্ত ক্রেতা চলে যাওয়া ক্রেতা 0 20 45 70 95 ১২০ সীমা · ১২৫: 401 0 10 41: 401 ৩০ মিনিট চুপ
ছবি ৪৫.২ — সবুজ দাগ: সক্রিয় থাকলেও ১২০ মিনিটের দেয়াল। নীল দাগ: ১০ মিনিটের পরে ৩০ মিনিটের চুপ-থাকা সীমা। লাল বিন্দু: 401.
// শেখার মডেল: simulated clock-এ idle আর absolute timeout
let now = 0;                                   // মিনিট, সত্যিকারের সময় নয়
const IDLE = 30, ABSOLUTE = 120;
class SessionStore {
  rows = new Map();
  next = 1;
  create(user) {
    const sid = 's' + this.next++;             // আসল id হবে এলোমেলো
    this.rows.set(sid, { user, created: now, seen: now });
    return sid;
  }
  get(sid) {
    const row = this.rows.get(sid);
    if (!row) return { ok: false, why: 'no row' };
    let why = null;
    if (now - row.created > ABSOLUTE) why = 'absolute';
    else if (now - row.seen > IDLE) why = 'idle';
    if (why) { this.rows.delete(sid); return { ok: false, why }; }
    row.seen = now;                            // sliding: সময় আবার গোনা শুরু
    return { ok: true, why: 'seen updated' };
  }
}
function visit(label, times) {
  const store = new SessionStore();
  now = 0;
  const sid = store.create('rahima');
  console.log(label);
  for (const t of times) {
    now = t;
    const { ok, why } = store.get(sid);
    console.log(`  t=${String(t).padStart(3)} min  ${ok ? '200' : '401'}  ${why}`);
  }
}
visit('busy shopper', [20, 45, 70, 95, 118, 125]);
visit('walks away', [10, 41, 42]);

আউটপুট: busy shopper-এ t=20 থেকে t=118 সব 200 seen updated, আর t=125 min 401 absolute. ব্যবধান কখনো ৩০-এর বেশি হয়নি, তবু শেষ অনুরোধ আটকেছে। walks away-এ t=10 ঠিক, t=41 min 401 idle, আর t=42 min 401 no row, কারণ প্রথম ব্যর্থতায় row মুছে গেছে। সময় এখানে সংখ্যা মাত্র, তাই ব্লক সঙ্গে সঙ্গে শেষ হয়।

সত্যিকারের run · একই server · idle ২ সেকেন্ড, absolute ৬ সেকেন্ডে নামিয়ে, দুটি সময়সীমা চোখে দেখার জন্য

 0.0 s login
 1.1 s ping 200 {"ok":true}
 3.1 s ping 200 {"ok":true}
 5.1 s ping 200 {"ok":true}
 6.1 s ping 401 {"error":"session_expired"}
 6.1 s login again
 9.7 s ping after 3.5 s silence 401 {"error":"not_authenticated"}

Idle-এর ক্ষেত্রে row মেয়াদ পেরিয়েছে, তাই server অচেনা session দেখেছে। PostgreSQL store-এ expire পুরো সেকেন্ডে ওপরের দিকে গোল হয়, তাই ৩০ মিনিটের সীমায় এক সেকেন্ডের হেরফের হতে পারে, যা এখানে অর্থহীন।

Sliding-এর দাম: প্রতি অনুরোধে একটি লেখা

express-session প্রতি উত্তরের শেষে store-এর touch ডাকে, যাতে row-এর মেয়াদ বাড়ে। মানে প্রতিটি পড়ার অনুরোধ একটি লেখাও। Trigger বসিয়ে গুনে দেখলাম:

সত্যিকারের run · PostgreSQL-এর session table-এ UPDATE গোনা

ডিফল্ট touch       ১,০০০ অনুরোধে UPDATE 1000
disableTouch       ১,০০০ অনুরোধে UPDATE 1

disableTouch + rolling + lastSeen প্রতি ১ সেকেন্ডে (idle ৪ সেকেন্ড)
8 s of pings 131 last 200 updates 7
after 6 s silence 401 {"error":"not_authenticated"}

শেষ দুই লাইনই সমাধান। disableTouch: true দিলে শুধু lastSeen বদলালে row লেখা হয়, আর সেই লেখাতেই মেয়াদ বাড়ে। ১৩১ অনুরোধে ৭টি লেখা, আট সেকেন্ড ধরে session বেঁচে ছিল, আর চুপ করলে ঠিকই শেষ হয়েছে। Idle সীমায় যে ভুল থাকে, তা সর্বোচ্চ ওই বিরতির সমান, আর ৩০ মিনিটের সীমায় ৬০ সেকেন্ড মেনে নেওয়া যায়।

হিসাব

৬০,০০০ জন সক্রিয় ক্রেতা, প্রত্যেকে গড়ে ৬ সেকেন্ডে একটি অনুরোধ: ৬০,০০০ ÷ ৬ = ১০,০০০ req/s. ডিফল্ট touch-এ ১০,০০০ UPDATE/s. মিনিটে একবার লিখলে ৬০,০০০ ÷ ৬০ = ১,০০০ UPDATE/s, মানে ৯০% কম। এই সংখ্যাগুলো ধরে নেওয়া, মাপা নয়।

৮ · Logout: এই device, সব device আর device তালিকা

Logout-এর সবচেয়ে সাধারণ ভুল হলো শুধু cookie মোছা। Browser ভুলে গেল, কিন্তু খাতায় নাম রয়েই গেল। সেই cookie-র কোনো কপি অন্য কোথাও থাকলে সেটি এখনো চলে। আসল logout মানে store থেকে row মোছা।

app.post('/api/logout', async (req, res) => {
  await destroy(req);                   // store থেকে row মুছল
  res.clearCookie(NAME, cookieOpts);    // browser-কেও বলা হলো
  res.status(204).end();
});
1await destroy(req);2res.clearCookie(NAME, cookieOpts);3res.status(204).end();
Store-এর row আর ল্যাপটপের cookie
ল্যাপটপ row
ফোন row
ট্যাব row
ল্যাপটপ cookie
  1. শুরু। রহিমা তিনটি device-এ login করা। Store-এ তিনটি আলাদা row, তিনটি আলাদা id.
  2. Row মুছল। ল্যাপটপ থেকে logout চাইলেন। destroy ল্যাপটপের row মুছল। এটিই আসল logout, বাকি দুটি row আগের মতো আছে।
  3. Cookie মোছা। উত্তরে sid=; আর ১৯৭০ সালের Expires গেল, ফলে browser cookie ফেলে দিল। Path আর অন্য attribute আগের মতোই।
  4. পুরোনো কপি। Cookie-র একটি কপি দিয়ে অনুরোধ এলে স্বাক্ষর ঠিক থাকে, কিন্তু row নেই। উত্তর 401.
  5. সব device. ফোন থেকে “সব device থেকে logout” চাইলেন। আগে ট্যাবের row মুছল, তারপর নিজের row destroy হলো। মোট ২টি session শেষ।
  6. ট্যাব জানল না। ট্যাবের cookie এখনো browser-এ আছে, তবু পরের অনুরোধ 401. কারণ সিদ্ধান্ত server-এর, cookie-র নয়।
// শেখার মডেল: logout শুধু browser-এ, নাকি server-এ
const http = require('node:http');
const sessions = new Map([['laptop', 'rahima'], ['phone', 'rahima'], ['tablet', 'rahima']]);
const CLEAR = 'sid=; Path=/; Max-Age=0; HttpOnly';
const server = http.createServer((req, res) => {
  const sid = /sid=(\w+)/.exec(req.headers.cookie ?? '')?.[1];
  const user = sessions.get(sid);
  res.statusCode = req.url === '/me' && !user ? 401 : 200;
  if (req.url === '/me') return res.end(user ?? 'not_authenticated');
  if (req.url === '/logout-weak') {            // ভুল: row থেকে গেল
    res.setHeader('set-cookie', CLEAR);
  } else if (req.url === '/logout') {          // এই device
    sessions.delete(sid);
    res.setHeader('set-cookie', CLEAR);
  } else if (req.url === '/logout-all') {      // এই ব্যবহারকারীর সব device
    for (const [id, u] of sessions) if (u === user) sessions.delete(id);
    res.setHeader('set-cookie', CLEAR);
  }
  res.end(`rows left: ${sessions.size}`);
});
server.listen(0, '127.0.0.1', async () => {
  const base = `http://127.0.0.1:${server.address().port}`;
  const hit = async (path, id) => {
    const r = await fetch(base + path, { headers: { cookie: `sid=${id}` } });
    console.log(path.padEnd(12), id.padEnd(7), r.status, await r.text());
  };
  await hit('/logout-weak', 'laptop');
  await hit('/me', 'laptop');                  // পুরোনো কপি দিয়ে অনুরোধ
  await hit('/logout', 'laptop');
  await hit('/me', 'laptop');
  await hit('/logout-all', 'phone');
  await hit('/me', 'tablet');
  server.close();
});

আউটপুট: /logout-weak laptop 200 rows left: 3, পরের /me laptop 200 rahima. অর্থাৎ cookie মুছতে বলার পরেও পুরোনো কপি চলছে। /logout laptop 200 rows left: 2-এর পরে /me laptop 401. শেষে /logout-all phone 200 rows left: 0 আর /me tablet 401 not_authenticated.

যে ভুলটি আমরা নিজেরাই করেছিলাম

“সব device থেকে logout”-এর প্রথম খসড়া ছিল সরল: একটি DELETE, তারপর clearCookie. কিন্তু req.session তখনো মেমরিতে বেঁচে আছে, আর উত্তরের শেষে express-session সেটি store-এ লিখতে চায়। lastSeen ওই অনুরোধেই বদলালে মোছা row আবার ফিরে আসে:

সত্যিকারের run · দুটি device · ভুল কোড, তারপর ঠিক কোড · cookie ছাঁটা

rows before 2
200 {"ended":2}
  sid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnl
  sid=s%3AmhtfV-p4P_D5Ocvo-BGK9WlYG1OPvGvr.4K8Iu72qnWgAzqGSCf5
rows after 1
old cookie /me 200 {"id":"6","name":"Rahima","email":"rahima@example.com"}

ঠিক কোডে
200 {"ended":2}
  sid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax
rows after 0
old cookie /me 401

উত্তর বলছে ২টি session শেষ, আর ঠিক তখনই একটি row ফিরে এসেছে, সাথে দ্বিতীয় একটি Set-Cookie. এই ভুল প্রতিবার ধরা পড়ে না: মিনিটে একবারই lastSeen বদলায়, তাই পরীক্ষায় পাস করে যায়। সমাধান: নিজের row-কে সাধারণ SQL-এ না ছুঁয়ে destroy দিয়ে মোছা, যাতে req.session-ও শেষ হয়।

app.post('/api/logout-all', requireAuth, async (req, res) => {
  const r = await pool.query(
    `DELETE FROM session WHERE sess->>'userId' = $1 AND sid <> $2`,
    [req.session.userId, req.sessionID]);
  await destroy(req);
  res.clearCookie(NAME, cookieOpts);
  res.json({ ended: r.rowCount + 1 });
});

“আপনার device”: session-এর তালিকা

রহিমা দেখতে চান কোথায় কোথায় login করা আছে, আর হারানো ফোনটি বাদ দিতে চান। তালিকায় কখনো sid দেখাবেন না, কারণ সেটিই চাবি। তাই login-এ আলাদা একটি device.id বসানো হয়েছে, যেটি জানাজানি হলে ক্ষতি নেই। মোছার SQL-এ userId-ও আছে, নয়তো একজন অন্যের device মুছতে পারতেন।

app.get('/api/sessions', requireAuth, async (req, res) => {
  const { rows } = await pool.query(
    `SELECT sess->'device'->>'id' AS id, sess->'device'->>'ua' AS ua,
            (sess->>'createdAt')::bigint AS created, (sess->>'lastSeen')::bigint AS seen
     FROM session WHERE sess->>'userId' = $1 AND expire > now()
     ORDER BY seen DESC`, [req.session.userId]);
  res.json(rows.map((d) => ({ ...d, current: d.id === req.session.device.id })));
});

app.delete('/api/sessions/:id', requireAuth, async (req, res) => {
  const r = await pool.query(
    `DELETE FROM session WHERE sess->>'userId' = $1 AND sess->'device'->>'id' = $2`,
    [req.session.userId, req.params.id]);
  res.status(r.rowCount ? 204 : 404).end();
});

সত্যিকারের run · তিনটি device · uuid আর সময় ছাঁটা · ua ল্যাবে হাতে বসানো

$ curl -s -b jar.txt 127.0.0.1:38746/api/sessions
[{"id":"974494a6-…","ua":"Firefox on Android","current":false},
 {"id":"661797a0-…","ua":"Safari on iPhone","current":false},
 {"id":"8e85d74a-…","ua":"Chrome on Windows","current":true}]

$ curl -si -X DELETE -b jar2.txt 127.0.0.1:38746/api/sessions/974494a6-…
HTTP/1.1 204 No Content
$ curl -si -b jar3.txt 127.0.0.1:38746/api/me
HTTP/1.1 401 Unauthorized
{"error":"not_authenticated"}

এখানে একটি মাপা ফাঁদ আছে। sess একটি JSON column, আর sess->>'userId'-এ index নেই। প্রকৃত আকার দেখতে ২,০০,০০০ row বসালাম:

সত্যিকারের run · PostgreSQL 18.6 · ২,০০,০০০ session · EXPLAIN ANALYZE ছাঁটা

table 92 MB   indexes 16 MB   row প্রতি 566 byte

index ছাড়া   Parallel Seq Scan on session
              Rows Removed by Filter: 66665     Execution Time: 89.836 ms

CREATE INDEX session_user_idx ON session ((sess->>'userId'));
index সহ      Index Scan using session_user_idx
              Buffers: shared hit=7             Execution Time: 0.014 ms

এই expression index ছাড়া প্রতিটি device-তালিকা পুরো table পড়ে। Index-এর দাম মাত্র ২ MB. Redis-এ value ধরে খোঁজা যায় না, তাই সেখানে user:1:sessions-এর মতো একটি set নিজে রাখতে হয়, আর মেয়াদ শেষে সেটি পরিষ্কারও করতে হয়। “সব device থেকে logout” কেবল বোতাম নয়: password বদল, account হ্যাক হওয়ার সন্দেহ, কর্মীর চাকরি ছাড়া, সব জায়গায় এটি লাগে।

৯ · React-এর দিক: /me, protected route আর CORS

HttpOnly cookie JavaScript পড়তে পারে না, আর সেটিই উদ্দেশ্য। তাহলে React জানবে কীভাবে কে login করেছেন? Server-কে জিজ্ঞেস করে: /api/me. এটি TanStack Query-র কাছে সাধারণ একটি server state (পর্ব ১৯), আর 401 এখানে error নয়, একটি উত্তর: “কেউ নেই”।

export async function api(path, options = {}) {
  const res = await fetch(API + path, {
    credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    ...options,
  });
  if (!res.ok) throw Object.assign(new Error(res.statusText), { status: res.status });
  return res.status === 204 ? null : res.json();
}

async function fetchMe() {
  try { return await api('/api/me'); }
  catch (err) { if (err.status === 401) return null; throw err; }
}

export const useMe = () => useQuery({
  queryKey: ['me'], queryFn: fetchMe, staleTime: 60_000, retry: false });

export function useLogout() {
  const qc = useQueryClient();
  const navigate = useNavigate();
  return useMutation({
    mutationFn: () => api('/api/logout', { method: 'POST' }),
    onSuccess: async () => {
      await qc.cancelQueries();
      qc.clear();                  // আগের ব্যবহারকারীর সব ডেটা মুছল
      navigate('/login', { replace: true });
    },
  });
}

export function RequireAuth() {
  const { data: me, isPending, isError } = useMe();
  const location = useLocation();
  if (isPending) return <p>যাচাই হচ্ছে…</p>;
  if (isError) return <p>Server-এ পৌঁছানো যায়নি।</p>;
  if (!me) return <Navigate to="/login" replace state={{ from: location.pathname }} />;
  return <Outlet />;
}

qc.clear() কেন, invalidateQueries নয়? Invalidate আগের ডেটা রেখে নতুন করে আনে, ফলে একই কম্পিউটারে পরের জনের স্ক্রিনে রহিমার অর্ডার ঝলকে উঠতে পারে। Clear সব মুছে দেয়। cancelQueries চলতে থাকা অনুরোধকে থামায়, যাতে পুরোনো উত্তর এসে আবার cache ভরে না দেয়। fetchMe 401-কে null বানায়, তাই সেখানে retry হয় না। retry: false না দিলে network ত্রুটিতে ডিফল্টে কয়েকবার চেষ্টা চলে, আর ততক্ষণ পাতা “যাচাই হচ্ছে” দেখায়। আর protected route শুধু UX. আসল পাহারা requireAuth-এর। Route-এর গঠন আছে পর্ব ২০-এ।

সত্যিকারের run · Edge 156 · Playwright · React 19.3.1, TanStack Query 5.105.1, React Router 8.5.1, Vite 8.3.5 · page localhost:38750, API localhost:38746

/account খুলতে গেলে       -> /login        GET me 401
ভুল password              "Email বা password ঠিক নেই।"   POST login 401
login-এর পরে              -> /account | Rahima
                          POST login 200, GET me 200, GET sessions 200
document.cookie = ""
cookie jar: name sid, domain localhost, httpOnly True, sameSite Lax
cache keys ['["me"]', '["sessions"]']
fetch without credentials: 401
fetch with credentials:    200
reload-এর পরেও            Rahima       GET me 200, GET sessions 200
logout-এর পরে             -> /login    cache keys []     POST logout 204
cookie jar size 0

প্রতিটি লাইন একটি দাবির প্রমাণ। document.cookie ফাঁকা, অথচ cookie আছে। credentials ছাড়া একই browser-এ 401. Reload-এ আগের মতো /me-ই পরিচয় ফিরিয়ে আনে। Browser-এর console-এ প্রথম 401-এর একটি লাল লাইন দেখাবে, ওটি স্বাভাবিক।

CORS আর একটি কঠিন ফাঁদ

Page আর API আলাদা origin-এ হলে দুটি জিনিস একসাথে লাগে: client-এ credentials: 'include', আর server-এ নির্দিষ্ট origin ও credentials: true. * এখানে চলে না, আর session-এর আগে cors বসাতে হয়। বিস্তারিত পর্ব ২৬-এ।

সত্যিকারের run · cors 2.8.6 · origin: 'https://www.bazarghar.example', credentials: true

OPTIONS /api/login  204
  access-control-allow-credentials: true
  access-control-allow-headers: content-type
  access-control-allow-methods: GET,HEAD,PUT,PATCH,POST,DELETE
  access-control-allow-origin: https://www.bazarghar.example
  vary: Origin, Access-Control-Request-Headers

এবার ফাঁদটি। ধরুন React চলছে localhost:38750-এ, আর .env-এ API লেখা 127.0.0.1:38746. দুটি একই যন্ত্র, কিন্তু browser-এর কাছে আলাদা site. একই কোড, তিনটি সেটআপ:

সেটআপPOST loginGET meCookie jar
localhost → localhost (অন্য port)200200sid
localhost → 127.0.0.1200401ফাঁকা
Vite server.proxy → একই origin200200sid

সত্যিকারের run · Edge 156 · একই Express আর React কোড

দ্বিতীয় সারিতে login সফল, কিন্তু browser cookie-টি রাখেইনি, কারণ SameSite=Lax cookie অন্য site-এর fetch-এর উত্তর থেকে জমা হয় না। স্ক্রিনে কোনো error নেই, শুধু login হয় না। Dev-এ Vite-এর proxy ('/api': 'http://127.0.0.1:38746') সবচেয়ে কম ঝামেলার, আর production-এর একই host নকশার সবচেয়ে কাছাকাছি।

পরীক্ষা ও ইন্টারভিউতে:

  • Session fixation কী, প্রতিরোধ কীভাবে? আগে থেকে জানা id login-এর পরে টিকে থাকা। Login-এ id বদলানো, Express-এ req.session.regenerate.
  • Idle আর absolute timeout-এর পার্থক্য? প্রথমটি চুপ থাকার সীমা, প্রতি অনুরোধে সরে। দ্বিতীয়টি login থেকে মোট সীমা, সরে না।
  • Logout-এ cookie মোছাই কি যথেষ্ট? না। Store থেকে row মুছতে হয়, নয়তো কপি করা cookie চলতে থাকে।
  • credentials: 'include' দিলে server-এ কী লাগে? নির্দিষ্ট Access-Control-Allow-Origin আর Access-Control-Allow-Credentials: true.
  • Proxy-র পেছনে Secure cookie আসে না কেন? Express সংযোগকে HTTP ভাবে। trust proxy-তে proxy-র সংখ্যা দিতে হয়।

১০ · AI-এর যুগে session login

Login কোড AI খুব দ্রুত লেখে, আর সেটি চলেও। সমস্যা হলো এর ভুলগুলো চোখে পড়ে না, কারণ happy path ঠিকই কাজ করে। ভুল ধরা পড়ে শুধু ব্যর্থতার পথ চালিয়ে।

AI-এর সাধারণ ভুলকীভাবে ধরবেন
Store ছাড়া express-sessionProduction mode-এ MemoryStore warning
পুরোনো connect-redis API, যেমন connectRedis(session)নিজের version-এর README, আর চালিয়ে দেখা
regenerate নেই, অথবা কার্ট হারায়Login-এর আগে-পরে sid মেলানো
Logout-এ শুধু clearCookieপুরোনো cookie দিয়ে আবার /me
trust proxy: true, origin: trueবানানো header দিয়ে req.ip দেখা
Hardcoded secretDiff পড়া; secret শুধু environment-এ
নেই এমন option বা packagenpm view আর অফিসিয়াল docs

নিচের prompt AI-কে কোড লিখতে নয়, বরং আপনার কোড ভাঙতে বলে। আসল secret বা production-এর cookie কখনো পেস্ট করবেন না।

আমার Express 5 আর express-session 1.19 দিয়ে লেখা login route নিচে দিলাম। PostgreSQL store, connect-pg-simple 10. [এখানে route-এর কোড পেস্ট করুন। secret আর আসল cookie বাদ দিন] নতুন কোড লিখো না। প্রতিটি প্রশ্নের উত্তর দাও লাইন ধরে: ১. login-এ session id বদলায় কোথায়, আর অতিথির কার্ট টিকে থাকে কীভাবে? ২. অজানা email আর ভুল password-এর উত্তর আর সময় কি একই? ৩. logout-এর পরে পুরোনো cookie দিয়ে /me করলে কী হবে? ৪. এই কোড proxy-র পেছনে চললে Secure cookie আসবে কি? ৫. যা যাচাই করতে পারছ না, সেটি "যাচাই দরকার" লিখে আলাদা করো। কোনো option বানিয়ো না।

AI-এর লেখা login হাতে পেলে: যাচাইয়ের তালিকা

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১Cookie ১৯৯৪ সালের জিনিস। এত নতুন পদ্ধতির পরেও session আর cookie আজও টিকে আছে কেন?

Cookie এসেছিল Netscape-এ, Lou Montulli-র হাতে, ১৯৯৪ সালের দিকে। সমস্যাটি ছিল আজকের মতোই: একটি অনলাইন দোকানের কার্ট মনে রাখা, অথচ HTTP প্রতিটি অনুরোধকে নতুন করে দেখে। তিন দশক পরেও মূল নকশা প্রায় একই, কারণ এটি একটি কঠিন সমস্যাকে সরল রাখে।

  • Browser নিজেই পাঠায়: আপনার JavaScript-কে token বইতে হয় না। HttpOnly থাকলে XSS হলেও script cookie-র মান পড়তে পারে না, যদিও সেই পাতায় script ক্ষতি করতে পারে (XSS আর CSP).
  • Server-এর হাতে নিয়ন্ত্রণ: একটি row মুছলেই session শেষ। হারানো ফোন, চাকরি ছাড়া কর্মী, হ্যাকের সন্দেহ, সব ক্ষেত্রেই কাজ সঙ্গে সঙ্গে হয়।
  • দাম: প্রতি অনুরোধে store পড়া, আর store-এর ওপর নির্ভরতা। আমাদের run-এ সেটি এক ms-এর কম, তবে store থামলে login করা সবাই আটকে যান।

JWT-তে পরিচয় token-এর ভেতরেই থাকে, তাই store পড়া বাঁচে, কিন্তু মেয়াদের আগে বাতিল করা কঠিন হয়। সেই দিকটি পর্ব ৪৬-এর বিষয়। মজার কথা হলো, বড় অনেক ব্যবস্থায় refresh token-এর জন্য শেষ পর্যন্ত আবার server-এ একটি row রাখতে হয়। “Stateless” হওয়ার ইচ্ছা প্রায়ই revocation-এর চাহিদার সামনে থেমে যায়।

একটি ব্রাউজারভিত্তিক সাধারণ সাইটের জন্য, যেখানে frontend আর API একই পরিবারের, session প্রায়ই সবচেয়ে সরল আর নিরাপদ শুরু। মোবাইল অ্যাপ বা বহু আলাদা service থাকলে হিসাব বদলাতে পারে।

মূল উপলব্ধি: পুরোনো হওয়া দুর্বলতা নয়। যে নকশা “সঙ্গে সঙ্গে বাতিল” দেয় সবচেয়ে কম কোডে, সেটি টিকে থাকে।

প্র ০২Session fixation আগে এত বড় সমস্যা ছিল কেন, আর আজকের framework কি নিজে থেকে এটি থামায়?

২০০২ সালে Mitja Kolšek-এর গবেষণাপত্রে ধারণাটি পরিষ্কার নাম পায়। তখন অনেক প্ল্যাটফর্ম session id URL-এ রাখত (;jsessionid=…, ?PHPSESSID=…), কারণ সব browser-এ cookie নির্ভরযোগ্য ছিল না। URL কপি হয়, log-এ ওঠে, referrer-এ যায়। তাই একটি জানা id অন্যের browser-এ পৌঁছানো সহজ ছিল। কিছু প্ল্যাটফর্ম URL-এ আসা অচেনা id-ও গ্রহণ করত।

আজ cookie সব browser-এ চলে, আর URL-এ id রাখার চল প্রায় উঠে গেছে। পরিস্থিতি বদলেছে, তবে ঝুঁকি শূন্য হয়নি।

  • Strict store: express-session স্বাক্ষরহীন id-কে গ্রহণ করে না। আমাদের run-এ বানানো id-তে 401 এসেছে, আর সেই id-তে কোনো নতুন row হয়নি। এটি একটি বড় স্তর।
  • তবু অতিথি session: Server-এর নিজের দেওয়া অতিথি id বৈধ, স্বাক্ষরও ঠিক। সেটি login-এর পরে টিকে থাকলে আগের জানা id-ই কাজ করে।
  • Subdomain থেকে cookie বসানো: দুর্বল কোনো subdomain পুরো domain-এর জন্য cookie বসাতে পারলে id চাপিয়ে দেওয়া সম্ভব। __Host- নাম এই পথটি বন্ধ করে, যা আমরা Edge-এ দেখেছি।

Express নিজে থেকে id বদলায় না, আপনাকে ডাকতে হয়। Django-র login(), Rails-এ Devise বা Laravel-এর auth-এর মতো পূর্ণ ব্যবস্থায় সাধারণত এটি ভেতরে করা থাকে, তবে নিজের version-এ যাচাই করে নেবেন। হাতে লেখা নকশায় id শুধু login-এ তৈরি হলে সমস্যাটিই থাকে না, যদিও অতিথির কার্ট তখন আলাদা cookie-তে রাখতে হয়।

মূল উপলব্ধি: নিরাপত্তা-ত্রুটি প্রায়ই পরিবেশ বদলালে রূপ বদলায়, হারায় না। নিয়মটি মনে রাখুন: পরিচয় বদলালে id বদলান।

প্র ০৩Session কোথায় রাখবেন: Redis, না PostgreSQL? একটি ছোট দল কীভাবে সিদ্ধান্ত নেবে?

আমাদের run-এ p50 ছিল ০.৪৬ বনাম ০.৬৫ ms. শুনতে Redis-এর জয়, কিন্তু সিদ্ধান্ত গতির চেয়ে অন্য প্রশ্নে বেশি নির্ভর করে।

  • নতুন কিছু চালানোর খরচ: শুধু PostgreSQL থাকলে session-ও সেখানে রাখলে নতুন সার্ভার, backup, monitoring লাগে না। Shared hosting বা ছোট VPS-এ এটি বড় কথা।
  • লেখার চাপ: Redis-এ TTL বাড়ানো খুব সস্তা। PostgreSQL-এ প্রতি UPDATE নতুন row-version বানায়, যা vacuum-কে কাজ দেয়। ট্রাফিক বাড়লে এটিই প্রথম চাপ। আগে লেখা কমান, যেমন disableTouch.
  • টিকে থাকা: ডিফল্টে মেমরিভিত্তিক Redis restart-এ ডেটা হারাতে পারে, আর ভুল eviction নীতিতে চাপের সময় session মুছে যায়। তখন সবাই হঠাৎ logout. Session-এর জন্য eviction নীতি noeviction ধরনের হওয়া দরকার, আর cache-এর সাথে একই instance রাখা ঝুঁকির (পর্ব ৪৪).
  • খোঁজার প্রয়োজন: Device তালিকা আর logout-all SQL-এ এক লাইনের। Redis-এ এর জন্য আলাদা set নিজে রক্ষা করতে হয়।
  • লেনদেন: PostgreSQL-এ password বদল আর সব session মোছা এক transaction-এ করা যায়।

তাই একটি যুক্তিসঙ্গত পথ: PostgreSQL দিয়ে শুরু, মেপে দেখা, আর সত্যিই চাপ এলে Redis. Store বদলাতে route-এর কোড প্রায় বদলায় না, বদলায় device তালিকা আর logout-all-এর অংশ। এটি নিয়ে মতভেদ আছে। অনেক দল শুরু থেকেই Redis নেয়, বিশেষত cache আর queue-এর জন্য এটি আগে থেকেই থাকলে।

মূল উপলব্ধি: ০.২ ms দিয়ে স্থাপত্য ঠিক করবেন না। ঠিক করুন কে এটি চালাবে, ভাঙলে কী হবে, আর কোন প্রশ্ন আপনাকে করতে হবে।

প্র ০৪Idle আর absolute timeout কত হওয়া উচিত? একটি নম্বর কি সবার জন্য ঠিক?

না। Timeout হলো দুই ঝুঁকির মধ্যে একটি ব্যবসায়িক সিদ্ধান্ত: বেশি লম্বা হলে পড়ে থাকা বা চুরি যাওয়া session বেশিক্ষণ কাজ করে, বেশি ছোট হলে মানুষ বিরক্ত হয়ে সহজ password বা শেয়ার করা account-এর দিকে যান। OWASP-এর নির্দেশিকা আর NIST SP 800-63B দুটিই ঝুঁকির স্তর ধরে আলাদা মান দেয়, আর সংস্করণে সেই মান বদলেছে। তাই সংখ্যা মুখস্থ না করে সর্বশেষ নথি দেখে নিন।

বাজারঘরে একই সাইটের ভেতরে দুটি জগৎ আছে।

  • সাধারণ কেনাকাটা: ক্ষতি সীমিত। লম্বা idle চলে, এমনকি কয়েক সপ্তাহের “মনে রাখুন”-ও।
  • Payment, ঠিকানা বদল: এখানে session পুরোনো হলে আবার password চাওয়া ভালো। একে step-up বলে (Session আর JWT-এর নিরাপত্তা). গোটা session ছোট না করেও সংবেদনশীল কাজ রক্ষা পায়।
  • Admin panel: ক্ষতি বড়, তাই ছোট idle, ছোট absolute আর আলাদা cookie.

বাংলাদেশের প্রেক্ষাপটে একটি বাস্তব কথা: অনেকে সাইবার ক্যাফে বা ভাগ করা ফোন ব্যবহার করেন। সেখানে লম্বা session মানে পরের জন account পেয়ে যান। “এই ডিভাইসে মনে রাখুন” তাই ব্যবহারকারীর ইচ্ছায় হওয়া উচিত, ডিফল্টে নয়। আবার ধীর network-এ লম্বা form পূরণের মাঝে ৫ মিনিটের idle সীমা পুরো লেখা নষ্ট করতে পারে।

আরেকটি প্রযুক্তিগত দিক: rolling থাকলে background polling-ও idle সময়কে সরিয়ে দেয়। একটি খোলা ট্যাব ৩০ সেকেন্ডে poll করলে session কখনো idle হবে না। তাই “মানুষ সক্রিয়” আর “অনুরোধ এসেছে” এক জিনিস নয়।

মূল উপলব্ধি: Timeout নিরাপত্তার একটি সংখ্যা নয়, ক্ষতির আকার ধরে নেওয়া একটি সিদ্ধান্ত। আর absolute সীমা ছাড়া sliding session চিরকাল বাঁচতে পারে।

প্র ০৫shop. আর account. দুই subdomain-এ একবারের login চাই। Domain দিয়ে cookie ছড়িয়ে দেওয়া কি ঠিক সমাধান?

এটি সবচেয়ে সহজ, আর কাজও করে: আমাদের Edge run-এ Domain=bazarghar.test cookie www-এ পৌঁছেছে। কিন্তু খরচটি লুকানো।

  • সব subdomain একই চাবি পায়: blog. যদি তৃতীয়-পক্ষের কোনো CMS-এ চলে, session cookie সেই server-এও যায়।
  • Subdomain takeover: ভুলে যাওয়া DNS রেকর্ড অন্য কারও দখলে গেলে সেই host cookie পড়তে আর বসাতে পারে। এটি বাস্তবে বহুবার দেখা গেছে।
  • __Host- বন্ধ: এই prefix-এর সবচেয়ে বড় সুরক্ষাটি ছাড়তে হয়। __Secure- নাম রাখা যায়, তবে সেটি কম কড়া, যা আমাদের পরীক্ষায় দেখা গেছে।

বিকল্পগুলো তুলনা করুন।

  • একই host, আলাদা path: bazarghar.example/account. Reverse proxy পেছনে আলাদা service-এ পাঠায়। সবচেয়ে সরল, cookie-ও __Host-.
  • কেন্দ্রীয় login: account. নিজের cookie রাখে, আর বাকিরা OAuth/OIDC দিয়ে তার কাছ থেকে পরিচয় নেয়। প্রতিটি subdomain-এর নিজের session থাকে। বেশি কাজ, কিন্তু সীমানা পরিষ্কার। ধারণাটি পর্ব ৪৭-এ আসবে।
  • BFF: প্রতিটি frontend-এর নিজের ছোট backend থাকে, যে নিজের host-এ cookie রাখে আর ভেতরে API ডাকে।

Domain-ও গ্রহণযোগ্য হতে পারে, যদি সব subdomain আপনার দলের হাতে, একই নিরাপত্তা স্তরে থাকে, আর পুরোনো DNS রেকর্ড নিয়মিত পরিষ্কার হয়। ছোট দলের জন্য সেটি বাস্তবসম্মত। কিন্তু সিদ্ধান্তটি লিখে রাখুন, আর নতুন subdomain খোলার সময় আবার ভাবুন, কারণ যে ঝুঁকি আজ নেই, সেটি একটি ভাড়া করা সেবা যোগ হলেই তৈরি হয়।

মূল উপলব্ধি: Cookie-র scope হলো বিশ্বাসের সীমানা। যাকে পুরো account-এর চাবি দিতে পারবেন না, তার host-এ cookie পাঠাবেন না।

অনুশীলন

আগে নিজে করুন, তারপর উত্তর মিলিয়ে নিন।

  1. হিসাব: বাজারঘরে ধরুন ৩০,০০০ জন একসাথে সক্রিয়, প্রত্যেকে ১০ সেকেন্ডে একটি অনুরোধ পাঠান। (ক) ডিফল্ট touch-এ প্রতি সেকেন্ডে কত UPDATE? (খ) ৬০ সেকেন্ডে একবার লিখলে কত? (গ) মোট ১০ লাখ session, প্রতি row ৫৬৬ byte ধরে table আর index মিলে কত জায়গা?

    (ক) ৩০,০০০ ÷ ১০ = ৩,০০০ UPDATE/s. (খ) ৩০,০০০ ÷ ৬০ = ৫০০ UPDATE/s, মানে প্রায় ৮৩% কম। (গ) ৫৬৬ × ১০,০০,০০০ = ৫৬,৬০,০০,০০০ byte ≈ ৫৬৬ MB (প্রায় ৫৪০ MiB). ৫৬৬ byte আমাদের ল্যাবের ২,০০,০০০ row-এর গড়, আসল আকার ua আর আপনার রাখা তথ্যের ওপর নির্ভর করে। মেয়াদ শেষের row মোছা না হলে এটি শুধু বাড়তেই থাকবে, তাই prune জরুরি।

  2. ফল অনুমান করুন: Session table ফাঁকা, saveUninitialized: false, login-এ regenerate আছে। ক্রমটি হলো: (১) ল্যাপটপে অতিথি কার্টে পণ্য তুললেন, id A. (২) সেই ল্যাপটপেই login. (৩) ফোনে আলাদাভাবে login. (৪) ফোন থেকে /api/logout. (৫) ল্যাপটপ থেকে /api/logout-all. প্রতি ধাপের পরে table-এ কয়টি row? শেষ ধাপের JSON কী?

    (১) ১: A. (২) ১: A মুছে নতুন id B. (৩) ২: B আর ফোনের C. (৪) ১: শুধু B. (৫) ০. শেষ ধাপে SQL অন্য কোনো row পায়নি, r.rowCount ০, তাই উত্তর {"ended":1}, শুধু নিজের session.

  3. কোড সমালোচনা: AI এই login দিল। অন্তত ৫টি সমস্যা বের করুন।
    app.use(session({ secret: 'keyboard cat', resave: true,
      saveUninitialized: true, cookie: { secure: false } }));
    app.set('trust proxy', true);
    app.post('/login', async (req, res) => {
      const u = await findUser(req.body.email);
      if (!u) return res.status(404).json({ error: 'No such user' });
      if (u.password !== sha256(req.body.password))
        return res.status(401).json({ error: 'Wrong password' });
      req.session.user = u;
      res.json(u);
    });
    • Hardcoded, পরিচিত secret. আসল মান environment থেকে আসবে।
    • Store নেই, মানে MemoryStore; resave ও saveUninitialized ভুল দিকে।
    • secure: false, আর httpOnly/sameSite নিজে স্পষ্ট করা নেই।
    • trust proxy: true বানানো X-Forwarded-For বিশ্বাস করে।
    • 404 আর 401 আলাদা, ফলে user enumeration; সময়ও আলাদা।
    • SHA-256 password-এর জন্য খুব দ্রুত, salt নেই। Argon2id লাগবে।
    • regenerate নেই, তাই fixation.
    • req.session.user = u আর res.json(u) password hash-সহ পুরো row পাঠায়। শুধু id রাখুন, দরকারি field পাঠান।
    • ইনপুট যাচাই নেই (পর্ব ২৭).
  4. নকশা: বাজারঘরে তিন ধরনের ব্যবহারকারী: ক্রেতা, বিক্রেতা আর admin. Admin panel admin.bazarghar.example-এ। প্রত্যেকের জন্য idle timeout, absolute timeout, cookie-র নাম ও scope, আর store-এর সিদ্ধান্ত লিখুন, এক লাইনের যুক্তিসহ।

    একটি যুক্তিসঙ্গত উত্তর, একমাত্র উত্তর নয়:

    • ক্রেতা: idle ৩০ মিনিট থেকে কয়েক ঘণ্টা, absolute ৭ দিন, __Host-sid, payment-এ step-up. ক্ষতি সীমিত, কেনাকাটায় বাধা কম।
    • বিক্রেতা: ক্রেতার মতোই store, তবে idle আর absolute ছোট, কারণ অর্ডার আর টাকার তথ্য আছে।
    • Admin: idle ১৫ মিনিট, absolute কয়েক ঘণ্টা, নিজের host-এ নিজের __Host-admin cookie, SameSite=Strict, আলাদা store prefix বা table. ফলে shop-এর কোনো ভুলে admin session ছোঁয়া যায় না, আর admin-এ MFA রাখা সহজ।

    মূল বিষয়: এক সাইট মানেই এক নীতি নয়। ক্ষতির আকার ধরে সীমা ঠিক হবে।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমSession-ভিত্তিক login-এ server-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. ইনপুট যাচাই আর email ছোট হাতের করা
  2. Email দিয়ে ব্যবহারকারী খুঁজে argon2 verify করা
  3. অতিথির কার্ট তুলে রেখে req.session.regenerate ডাকা
  4. নতুন session-এ userId, সময় আর device বসানো
  5. req.session.save শেষ হওয়ার অপেক্ষা করা
  6. নতুন Set-Cookie-সহ উত্তর পাঠানো

Session-ভিত্তিক login-এ cookie-তে সাধারণত কী রাখা উচিত?

  • Password-এর hash
  • একটি এলোমেলো session id
  • ব্যবহারকারীর নাম, email আর role
  • Session স্বাক্ষরের secret

Express অ্যাপ nginx-এর পেছনে চলছে, cookie.secure: true, কিন্তু trust proxy বসানো হয়নি। Login করলে কী ঘটবে?

  • Express 500 error দেবে
  • Cookie যাবে, তবে Secure ছাড়া
  • Login 200 দেবে, কিন্তু কোনো Set-Cookie আসবে না
  • nginx নিজে cookie বসিয়ে দেবে

__Host-sid=1; Domain=bazarghar.example; Path=/; Secure header পেলে একটি আধুনিক browser কী করবে?

  • Cookie-টি জমা না রেখে ফেলে দেবে
  • Domain বাদ দিয়ে host-only cookie রাখবে
  • সব subdomain-এ পাঠাবে
  • রাখবে, শুধু console-এ সতর্ক করবে

Idle timeout ৩০ মিনিট, absolute timeout ১২০ মিনিট। একজন ক্রেতা ০ মিনিটে login করে ২০, ৪৫, ৭০, ৯৫, ১১৮ আর ১২৫ মিনিটে অনুরোধ পাঠালেন। কোন অনুরোধটি প্রথম 401 পাবে?

  • ২০ মিনিটের
  • ৪৫ মিনিটের
  • ১১৮ মিনিটের
  • ১২৫ মিনিটের

React অ্যাপে logout সফল হওয়ার পরে TanStack Query-তে কোনটি সবচেয়ে ঠিক?

  • শুধু invalidateQueries ডাকা
  • cancelQueries, তারপর clear
  • শুধু ['me'] key null করা
  • staleTime ০ করে দেওয়া

React চলছে http://localhost:5173-এ, API http://127.0.0.1:3000-এ, cookie SameSite=Lax. CORS আর credentials: 'include' ঠিক আছে। Login 200 দিচ্ছে, কিন্তু /me 401. কারণ কী?

  • CORS-এ POST অনুমোদিত নয়
  • Regenerate-এ session হারিয়ে গেছে
  • দুটি ঠিকানা আলাদা site, তাই Lax cookie কাজ করছে না
  • Port আলাদা হলেই cookie চলে না

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Cookie-তে শুধু এলোমেলো id, পরিচয় থাকে store-এ। Password-এর জায়গায় থাকে argon2id hash, আর ব্যর্থতার বার্তা ও সময় থাকে এক।
  2. Login-এ regenerate, দরকারি ডেটা আগে তুলে রেখে, আর উত্তরের আগে save.
  3. Cookie: __Host-, HttpOnly, Secure, SameSite=Lax, Path=/. Proxy-র পেছনে trust proxy গুনে বসান।
  4. Idle আসে rolling আর maxAge থেকে, absolute লিখতে হয় নিজে। Logout মানে store থেকে row মোছা, আর logout-all-এ নিজের row destroy.
  5. React-এ credentials: 'include', ['me'] query, protected route, logout-এ clear. আসল পাহারা server-এ।

পরের পর্বে: Session-এ প্রতি অনুরোধে store পড়তে হয়। যদি পরিচয়টি token-এর ভেতরেই থাকে? পর্ব ৪৬-এ JWT, access আর refresh token, আর দেখব কেন এই সহজ শোনা পথে logout কঠিন হয়ে যায়।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান