এই পর্বে যা শিখবেন
- কখন JWT নেবেন, আর কখন session-ই ভালো সিদ্ধান্ত
- Express 5 আর
joseদিয়ে token তৈরি, যাচাই আর নিজের verifier-এর regression পরীক্ষা - Prisma-তে hash করা refresh token, rotation আর reuse detection
- Browser-এ token রাখার জায়গার সৎ তুলনা, আর logout কেন কঠিন
- React-এ memory-তে token, 401-এ একবার refresh আর single-flight
- AI-এর লেখা auth কোড যাচাই করার তালিকা
সীমারেখা: এই পর্ব নিজের অ্যাপ রক্ষার জন্য। সব token নকল key আর খেলনা ডেটায় তৈরি। অন্যের token নেওয়া বা অন্যের সিস্টেম পরীক্ষা করা অনুমতি ছাড়া অপরাধ হতে পারে।
১ · JWT কখন মানায়, কখন session-ই সহজ
পর্ব ৪৫-এ (Session-ভিত্তিক login) server একটি এলোমেলো ID দেয়, আর তথ্য থাকে store-এ। এখানে দেখব অন্য পথ, JWT, যেখানে তথ্য স্বাক্ষর করা অবস্থায় token-এর ভেতরেই থাকে। Token-এর তত্ত্ব, RFC-র নিয়ম আর cookie flag-এর ব্যাখ্যা আছে সাইবার নিরাপত্তা কোর্সের Session আর token পর্বে. সেটি আমরা আবার পড়াব না। এখানে আমরা বাজারঘরের জন্য জিনিসটি বানাব।
চায়ের দোকানের হিসাব খাতার কথা ভাবুন। আপনি নাম বললে দোকানদার খাতা খুলে দেখেন। এটি session: প্রতিবার খাতা লাগে, কিন্তু খাতায় দাগ কাটলেই বাকি বন্ধ। এবার ভাবুন বাজার কমিটির সিলমোহর দেওয়া কুপন, তাতে নাম আর মেয়াদ লেখা। যেকোনো দোকান সিল দেখেই মেনে নেয়, খাতা লাগে না। কিন্তু কুপন একবার হাতছাড়া হলে মেয়াদ পর্যন্ত চলে। JWT হলো সেই কুপন।
| পরিস্থিতি | সহজ পছন্দ | কারণ |
|---|---|---|
| একটি web app, পাতা আর API একই domain-এ | Session | Cookie নিজে যায়, logout সঙ্গে সঙ্গে কাজ করে |
| আলাদা API, যা web আর মোবাইল অ্যাপ দুটোই ব্যবহার করে | JWT মানায় | মোবাইলে cookie-র সুবিধা কম, header-এ token পাঠানো সহজ |
| কয়েকটি service, প্রতিটি নিজে যাচাই করবে | JWT মানায় | প্রতিটি service কেন্দ্রীয় store না ডেকেই স্বাক্ষর দেখতে পারে |
| ব্যাংকিং ধরনের কাজ, বাতিল হতে হবে এখনই | Session বা opaque token | কয়েক মিনিটের দেরিও মেনে নেওয়া যায় না |
একটি কথা সৎভাবে বলা দরকার। এই পর্বের নকশায় refresh token database-এ থাকে। তাই “JWT মানে database লাগে না” কথাটি এখানে সত্য নয়। লাভ হলো প্রতিটি API request-এ database ডাকতে হয় না, শুধু কয়েক মিনিট পরপর একবার। সম্প্রদায়ে এই নিয়ে মতভেদ আছে। অনেক অভিজ্ঞ প্রকৌশলী সাধারণ web app-এ JWT না নিতেই বলেন। বাজারঘরে একটি web অ্যাপ আর একটি মোবাইল অ্যাপ একই API ডাকবে, তাই আমরা JWT বেছে নিচ্ছি।
যা বানাব: কোন token কোথায় থাকে
Access token in memory, refresh token in an HttpOnly cookie, only hashes in the database
Path শুধু /api/auth, তাই সাধারণ API request-এ সেটি যায় না।২ · একটি JWT খুলে দেখা
JWT-তে তিনটি টুকরো থাকে, মাঝে ডট: header, payload আর signature. প্রথম দুটি শুধু base64url encoding. তৃতীয়টি গোপন key দিয়ে প্রথম দুটির ওপর হিসাব করা HMAC. পরের অংশে বানানো ল্যাবের একটি token decodeProtectedHeader আর decodeJwt দিয়ে খুলেছি। মানগুলো নকল।
// সত্যিকারের run, jose 6.2.14, src/verify-cases.ts
header { alg: 'HS256', typ: 'at+jwt' }
payload {
role: 'customer',
iss: 'https://api.bazarghar.example',
aud: 'bazarghar-web',
sub: '42',
jti: 'demo-jti-0001',
iat: 1793592000,
exp: 1793592300
}
signature r2JG6sszWu6ouhqpOOTAdbT0pvQbH5V3p2JZYNO_MA8
| Claim | অর্থ | Verifier যা করে |
|---|---|---|
iss | কে বানিয়েছে | প্রত্যাশিত issuer-এর সঙ্গে হুবহু মেলায় |
aud | কার জন্য বানানো | নিজের নাম আছে কি না দেখে |
sub | কার সম্পর্কে, এখানে user id | থাকতে হবে; string হিসেবে আসে |
exp | মেয়াদ শেষের সময়, UNIX সেকেন্ডে | এখনকার সময়ের সঙ্গে তুলনা করে |
jti | এই token-এর নিজের ID | Denylist-এ খোঁজার চাবি |
iat | তৈরির সময় | বয়স হিসাবের জন্য, ইচ্ছা হলে |
এখানে exp − iat = 300, মানে ৫ মিনিট। Claim-গুলোর কথা যাচাইয়ের আগে বিশ্বাস করা যায় না। এবার node:crypto দিয়ে আসল HS256 নিজে বানান। এটি শেখার মডেল, production-এর জন্য নয়, তবু token-টি সত্যিকারের JWT.
// শেখার মডেল, production-এর জন্য নয়: হাতে লেখা আসল HS256 JWT
const { createHmac, timingSafeEqual } = require("node:crypto");
const b64u = (x) => Buffer.from(x).toString("base64url");
const text = (s) => Buffer.from(s, "base64url").toString();
const mac = (msg, key) => createHmac("sha256", key).update(msg).digest();
function sign(claims, key) {
const head = b64u(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const body = b64u(JSON.stringify(claims));
return `${head}.${body}.${b64u(mac(`${head}.${body}`, key))}`;
}
function verify(token, key) {
const parts = token.split(".");
if (parts.length !== 3) return "REJECT shape";
const [head, body, sig] = parts;
if (JSON.parse(text(head)).alg !== "HS256") return "REJECT alg";
const want = mac(`${head}.${body}`, key);
const got = Buffer.from(sig, "base64url");
if (got.length !== want.length || !timingSafeEqual(got, want))
return "REJECT signature";
return "ACCEPT " + text(body);
}
const key = "example-secret-lesson-only-0123456789";
const token = sign({ sub: "42", role: "customer", exp: 1793592300 }, key);
console.log(token);
const [h, b, s] = token.split(".");
console.log("no key needed:", text(h), text(b));
console.log("valid :", verify(token, key));
const changed = b64u(text(b).replace("customer", "admin"));
console.log("tampered:", verify(`${h}.${changed}.${s}`, key));
console.log("bad key :", verify(token, "another-example-secret"));প্রথম লাইনে আসল token: eyJhbGciOiJIUzI1NiIs…. দ্বিতীয় লাইন no key needed: {"alg":"HS256","typ":"JWT"} {"sub":"42","role":"customer","exp":1793592300} দেখায়, key ছাড়াই সব পড়া গেছে। valid : ACCEPT এল, কারণ স্বাক্ষর মিলেছে। customer বদলে admin করলে tampered: REJECT signature, কারণ স্বাক্ষরটি পুরোনো লেখার। ভুল key-তেও bad key : REJECT signature. খেয়াল করুন, alg-এর তুলনা স্বাক্ষরের আগে, আর তুলনায় timingSafeEqual, সাধারণ === নয়।
Payload-এ গোপন কিছু নয়। ওপরের দ্বিতীয় লাইনই প্রমাণ। Token যায় log, browser আর proxy দিয়ে। তাই password hash, NID, ফোন নম্বর, ঠিকানা বা অভ্যন্তরীণ key কখনো নয়। sub আর ছোট একটি role যথেষ্ট। বাকি তথ্য API দরকারমতো database থেকে আনবে। আরেকটি সত্য: token-এ লেখা role মেয়াদ পর্যন্ত পুরোনো থাকতে পারে। স্পর্শকাতর অনুমতি তাই পর্ব ৪৮-এর মতো server-এর ডেটায় দেখা ভালো।
৩ · Express 5-এ jose দিয়ে token বানানো আর যাচাই
হাতে লেখা কোডটি বোঝার জন্য ছিল। আসল অ্যাপে পরিচিত library নিন। আমরা jose ব্যবহার করব, যা Web Crypto-র ওপর চলে, তাই Node-এর বাইরে edge runtime-এও চলে। লেখার সময়ে version:
$ npm install express@5.3.0 jose@6.2.14 cookie-parser@1.4.7 zod
$ npm install @prisma/client@7.10.1 @prisma/adapter-pg@7.10.1 pg
$ npm install -D prisma@7.10.1 tsx typescript@~6.0
found 0 vulnerabilities (সত্যিকারের run, Node 24)
Secret আসে environment variable থেকে (পর্ব ৭৮), কোডে নয়। HS256-এর key অন্তত ৩২ byte এলোমেলো হওয়া উচিত, মনে রাখার মতো password নয়।
import { randomUUID } from 'node:crypto';
import { SignJWT, jwtVerify } from 'jose';
import { config } from './config.js';
export function signAccess(user: { id: number; role: string }, familyId: string) {
return new SignJWT({ role: user.role, fid: familyId })
.setProtectedHeader({ alg: 'HS256', typ: 'at+jwt' })
.setIssuer(config.issuer)
.setAudience(config.audience)
.setSubject(String(user.id))
.setJti(randomUUID())
.setIssuedAt()
.setExpirationTime(config.accessTtl) // '5m'
.sign(config.secret); // Uint8Array
}
export function verifyAccess(token: string) {
return jwtVerify(token, config.secret, {
algorithms: ['HS256'], // pinning
issuer: config.issuer,
audience: config.audience,
typ: 'at+jwt',
requiredClaims: ['sub', 'jti', 'exp'],
clockTolerance: 5, // সেকেন্ড
});
}typ: 'at+jwt' ধারণাটি RFC 9068 থেকে নেওয়া। এতে অন্য কাজের জন্য বানানো JWT, যেমন email যাচাইয়ের token, ভুল করে access token হিসেবে চলে না। এবার পর্ব ২৬-এর middleware. Express 5-এ async middleware-এর rejected promise নিজেই error handler-এ যায়, তবে এখানে আমরা ইচ্ছা করে ধরছি, যাতে উত্তর হয় পরিষ্কার 401.
async function requireAuth(req: Request, res: Response, next: NextFunction) {
const [scheme, token] = (req.headers.authorization ?? '').split(' ');
res.set('WWW-Authenticate', 'Bearer');
if (scheme !== 'Bearer' || !token) return problem(res, 401, 'missing_token');
try {
const { payload } = await verifyAccess(token);
if (config.denylist && denied.has(payload.jti!)) return problem(res, 401, 'token_revoked');
res.removeHeader('WWW-Authenticate');
res.locals.auth = payload;
next();
} catch (e) {
console.log(`auth.verify failed path=${req.path} code=${(e as { code?: string }).code}`);
return problem(res, 401, 'invalid_token');
}
}ব্যবহারকারীকে শুধু invalid_token বলা হয়, ভেতরের কারণ যায় log-এ। Token কখনো log-এ নয়। problem হলো পর্ব ৩৩-এর problem details-এর ছোট রূপ।
নিজের verifier-এর পরীক্ষা
ভালো token গৃহীত হওয়া যথেষ্ট প্রমাণ নয়। খারাপ token বাতিল হচ্ছে কি না, সেটিই আসল পরীক্ষা। ল্যাবের ফাইলে নিজের key-তে কিছু ভুল token বানিয়ে নিজের verifier-এ দিয়েছি। ঘড়ি currentDate দিয়ে স্থির।
$ npx tsx src/verify-cases.ts # সত্যিকারের run, jose 6.2.14
valid ACCEPT sub=42
payload changed REJECT ERR_JWS_SIGNATURE_VERIFICATION_FAILED
other secret REJECT ERR_JWS_SIGNATURE_VERIFICATION_FAILED
wrong aud REJECT ERR_JWT_CLAIM_VALIDATION_FAILED aud
wrong iss REJECT ERR_JWT_CLAIM_VALIDATION_FAILED iss
wrong typ REJECT ERR_JWT_CLAIM_VALIDATION_FAILED typ
HS512 not pinned REJECT ERR_JOSE_ALG_NOT_ALLOWED
alg none REJECT ERR_JOSE_ALG_NOT_ALLOWED
exp + 3s (in leeway) ACCEPT sub=42
exp + 6s (past) REJECT ERR_JWT_EXPIRED exp
প্রতিটি লাইন একটি নিয়মের প্রমাণ। এই তালিকা unit test হিসেবে রাখুন। Library বা তার version বদলালে এই test-ই আগে বলে দেবে কোনো নিয়ম ঢিলে হলো কি না।
Clock skew আর ছাড়
Auth server আর API server-এর ঘড়ি হুবহু এক নয়। এই সামান্য পার্থক্যকে বলে clock skew. তাই clockTolerance দিয়ে কয়েক সেকেন্ড ছাড় রাখা হয়। ওপরের দুই লাইনেই তা দেখা গেছে: মেয়াদের ৩ সেকেন্ড পরে গৃহীত, ৬ সেকেন্ড পরে বাতিল। নিচের ব্লকে jose-এর সোর্সের একই শর্ত, সিমুলেটেড ঘড়িতে। Token তৈরি হয়েছে 1000 সময়ে, মেয়াদ 300 সেকেন্ড।
// সিমুলেটেড ঘড়ি: jose-এর নিয়মেই exp আর nbf দেখা
function timeCheck(c, now, leeway) {
if (c.nbf !== undefined && c.nbf > now + leeway) return "too early";
if (c.exp <= now - leeway) return "expired";
return "ok";
}
const issued = 1000; // token তৈরির সময় (auth server-এর ঘড়ি)
const claims = { nbf: issued, exp: issued + 300 };
console.log("api-clock leeway=0 leeway=5");
for (const now of [994, 996, 1000, 1299, 1300, 1304, 1305]) {
const a = timeCheck(claims, now, 0).padEnd(10);
console.log(String(now).padEnd(10), a, timeCheck(claims, now, 5));
}ঠিক 1300-এ ছাড় ছাড়া ফল expired, কারণ শর্তটি exp <= now. ছাড় ৫ হলে 1304 পর্যন্ত ok, আর 1305-এ expired. উল্টো দিকে API-র ঘড়ি ৪ সেকেন্ড পিছিয়ে থাকলে (996) ছাড় ছাড়া সদ্য তৈরি token হতো too early. এটিই ছাড়ের আসল কাজ। ছাড় মানে token-এর আয়ু বাড়া: ৫ মিনিটের token আসলে ৫ মিনিট ৫ সেকেন্ড চলে। তাই ছাড় সেকেন্ডের ঘরে রাখুন, মিনিটে নয়।
jsonwebtoken বিকল্প: Node-এ এটি বহুদিনের জনপ্রিয় library, লেখার সময়ে 9.0.3. ল্যাবে একই নিয়মে চালিয়ে দেখেছি। নিয়ম একই: jwt.verify(t, secret, { algorithms: ['HS256'], issuer, audience, clockTolerance: 5 }). দুই library-র মধ্যে পার্থক্য হলো jose শুধু ESM আর Web Crypto-র ওপর চলে, তাই edge runtime-এ সুবিধা। পুরোনো CommonJS প্রকল্পে jsonwebtoken সহজ হতে পারে।
৪ · Algorithm pinning, পুরোনো ভুল আর key বদল
Token-এর header আসে ব্যবহারকারীর হাত ঘুরে। তাই “এটি কোন algorithm-এ যাচাই করব” প্রশ্নের উত্তর header থেকে নেওয়া যায় না। কোডে স্থির তালিকা দেওয়াকে বলে algorithm pinning. ওপরের algorithms: ['HS256'] সেই কাজই করে।
২০১৫ সালে প্রকাশিত নিরাপত্তা-গবেষণায় কিছু JWT library-র দুটি গোড়ার ভুল সামনে আসে। একটি alg: none: স্বাক্ষরহীন token-কে বৈধ ধরা। অন্যটি algorithm confusion: RS256-এর public key-কে HMAC-এর গোপন key ভাবা। বিস্তারিত ব্যাখ্যা সাইবার নিরাপত্তা কোর্সে আছে। Developer হিসেবে আপনার কাজ তিনটি:
- প্রতিটি verify call-এ algorithm তালিকা দেওয়া, যদিও library-র default ভালো মনে হয়।
- একই verify পথে কখনো HMAC আর public-key algorithm না মেশানো। RFC 8725 (২০২০) বলে, প্রতিটি key একটিই algorithm-এর জন্য।
- ওপরের মতো regression test রাখা, যাতে
noneআর ভুলalgবাতিল হওয়ার প্রমাণ থাকে।
ল্যাবে একটি Ed25519 verifier-কে HS256 token দিয়েছি। ফল HS token to Ed25519 verifier: ERR_JOSE_ALG_NOT_ALLOWED. Key দিয়ে কিছু হিসাব করার আগেই তালিকায় আটকে গেছে।
HS256, নাকি public-key স্বাক্ষর?
| Algorithm | কে যাচাই করতে পারে | কখন মানায় |
|---|---|---|
| HS256 | যাঁর কাছে গোপন key, তিনি স্বাক্ষরও করতে পারেন | একটি API নিজের token নিজে যাচাই করে। বাজারঘরের এখনকার নকশা |
| RS256 | Public key থাকলেই যাচাই, স্বাক্ষর নয় | পুরোনো সিস্টেমের সঙ্গে মেলাতে হলে; signature বড় |
| Ed25519 (EdDSA) | Public key থাকলেই যাচাই, স্বাক্ষর নয় | অনেক service, নতুন নকশা; signature ছোট |
ল্যাবের একটি মাপ: একই ধরনের claim-এ Ed25519-এর signature অংশ ৮৬ অক্ষর, HS256-এর ৪৩ অক্ষর। RFC 9864 (২০২৫) সাধারণ EdDSA নামের বদলে নির্দিষ্ট Ed25519 নাম এনেছে, আর jose 6.2.14 দুটোই চেনে। আপনার অন্য service-এর library কোনটি চেনে, আগে দেখে নিন।
Key বদল: kid আর JWKS
Key চিরকাল রাখা যায় না। ফাঁসের সন্দেহ হলে বা নিয়মিত সময় পরে বদলাতে হয়। Public-key নকশায় issuer একটি URL-এ JWKS, মানে public key-র তালিকা, প্রকাশ করে। Token-এর header-এর kid বলে দেয় তালিকার কোন key লাগবে। মনে রাখবেন, kid শুধু জানা তালিকা থেকে বাছাই করে। সে নিজে কোনো key আনে না। নিচে সত্যিকারের run, Node-এর একটি ছোট server আর createRemoteJWKSet দিয়ে:
$ npx tsx src/jwks-demo.ts # সত্যিকারের run, jose 6.2.14
old key ACCEPT kid=2026-10 jwks-fetches=1
old key again (cached) ACCEPT kid=2026-10 jwks-fetches=1
new kid, cache miss ACCEPT kid=2026-11 jwks-fetches=2
old token still valid ACCEPT kid=2026-10 jwks-fetches=2
new after removal ACCEPT kid=2026-11 jwks-fetches=2
ধাপের ক্রমটিই শিক্ষা। আগে নতুন key প্রকাশ, তারপর নতুন key-তে স্বাক্ষর, আর পুরোনো token-গুলোর মেয়াদ শেষ হলে তবেই পুরোনো key সরানো। অচেনা kid এলে verifier তালিকা একবার নতুন করে আনে, যা jwks-fetches=2 লাইনে দেখা যায়। ল্যাবে cooldownDuration: 0 ছিল শুধু demo-র জন্য। Production-এ default cooldown রাখুন, যাতে অচেনা kid-এর ঝড়ে JWKS endpoint-এ চাপ না পড়ে।
৫ · ছোট access token, লম্বা refresh token
মেয়াদ বড় হলে token চুরির ক্ষতি বড়। মেয়াদ ছোট হলে ব্যবহারকারীকে বারবার login করতে হয়। সমাধান হলো দুই token. Access token JWT, কয়েক মিনিট চলে, প্রতি API request-এ যায়। Refresh token এলোমেলো মান, মানে opaque token, কয়েক দিন চলে, যায় শুধু auth পথে। ল্যাবের মান: access ৫ মিনিট, refresh ১৪ দিন, আর একটি login-এর সর্বোচ্চ সীমা ৩০ দিন। এগুলো ঝুঁকি দেখে ঠিক করার মান, কোনো সার্বজনীন নিয়ম নয়।
হিসাব: ধরুন একসাথে সক্রিয় ব্যবহারকারী ২০,০০০ (ধরে নেওয়া মান)। প্রত্যেকে প্রতি ৩০০ সেকেন্ডে একবার refresh করলে সেকেন্ডে 20000 / 300 ≈ 66.7টি refresh. Access token ১৫ মিনিট হলে 20000 / 900 ≈ 22.2টি। মানে refresh-এর চাপ কমে, কিন্তু চুরির সময়সীমা তিনগুণ হয়। ল্যাবের access token ছিল ৩৬৯ অক্ষর, session ID ধরনের একটি cookie মান ৪৩ অক্ষর। JWT প্রতি request-এ বাড়তি ডেটা নিয়ে যায়, তাই claim ছোট রাখুন।
Database-এ দুটি table. Token family মানে একটি login, সাধারণত একটি device. পর্ব ৩৯-এর মতো Prisma schema:
model TokenFamily {
id String @id @default(uuid())
userId Int
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
expiresAt DateTime // absolute সীমা
revokedAt DateTime?
tokens RefreshToken[]
@@index([userId])
}
model RefreshToken {
id Int @id @default(autoincrement())
tokenHash String @unique // SHA-256, কাঁচা token নয়
familyId String
family TokenFamily @relation(fields: [familyId], references: [id])
createdAt DateTime @default(now())
expiresAt DateTime
usedAt DateTime?
@@index([familyId])
}Refresh token ৩২ byte এলোমেলো, randomBytes(32) থেকে। Database-এ থাকে শুধু তার SHA-256. Database ফাঁস হলেও সেখান থেকে চালু token পাওয়া যায় না। Password-এর মতো ধীর hash এখানে লাগে না, কারণ মানটি নিজেই ২৫৬ bit এলোমেলো, অনুমান করার কিছু নেই। বাতিলের চিহ্ন family-তে একটি সারিতে থাকে, প্রতিটি token-এ নয়। ফলে “বাতিল” লেখার সময় একই মুহূর্তে একটি নতুন token তৈরি হলেও সেটি বেঁচে যায় না, কারণ প্রতিবার family দেখা হয়।
নিচের ধাপগুলোতে পুরো পথ দেখুন। Cookie-র মান Fxmn-q… ছোট করে দেখানো, hash-গুলো একই run-এর log থেকে নেওয়া। শুধু ৫ নম্বর ধাপের log লাইনটি browser run-এর।
- React
memory, cookieব্যবহারকারীর দিক - Express API
joseনিয়ম এখানে - PostgreSQL
tokenHashশুধু hash
{ "email": "rahima@example.com",
"password": "example-password" }
scrypt hash মিলল
auth.login user=1 family=b4c11bbe token=a8bdfac8 database-এ a8bdfac8… (hash), কাঁচা token নয়
Set-Cookie: __Secure-rt=Fxmn-q…;
Max-Age=1209600; Path=/api/auth;
HttpOnly; Secure; SameSite=Strict
{ "accessToken": "eyJhbGciOiJIUzI1NiIs…",
"user": { "id": 1, "name": "রহিমা" } }
Authorization: Bearer eyJhbGci… jose: স্বাক্ষর, iss, aud, typ, exp ঠিক 200 {"sub":"1","role":"customer"}
auth.verify failed path=/me
code=ERR_JWT_EXPIRED
401 {"title":"invalid_token","status":401}
auth.refresh ok family=b4c11bbe
used=a8bdfac8 new=0583be17
Set-Cookie: __Secure-rt=(নতুন মান); …
{ "accessToken": "eyJhbGciOiJIUzI1NiIs…" }
Authorization: Bearer (নতুন token) 200 {"sub":"1","role":"customer"} ব্যবহারকারী কিছুই টের পাননি
- Login. React email আর password পাঠাল। API hash মেলাল। ভুল হলে উত্তর একই
bad_credentials, কোন অংশ ভুল তা বলা হয় না। - নতুন family. API একটি
TokenFamilyসারি আর প্রথম refresh token বানাল। Database-এ গেল শুধু hasha8bdfac8…. - দুই token ফেরত। Access token গেল JSON-এ, React সেটি memory-তে রাখবে। Refresh token গেল
HttpOnlycookie-তে, যা JavaScript দেখতে পায় না।Max-Age=1209600মানে ১৪ দিন। - সাধারণ request. React header-এ token দিল। API শুধু key দিয়ে যাচাই করল। Database একবারও ডাকা হয়নি। এই পথেই JWT-র লাভ।
- মেয়াদ শেষ। ৫ মিনিট আর ছাড়ের পরে
ERR_JWT_EXPIRED. API 401 দিল। এটি ভুল নয়, স্বাভাবিক ঘটনা। - Refresh. React
/api/auth/refreshডাকল। Cookie নিজে গেল, কারণ path মিলেছে।a8bdfac8ব্যবহৃত হলো, নতুন0583be17এল, সঙ্গে নতুন access token. - Retry. আগের request নতুন token নিয়ে আবার গেল, আর 200 পেল। Retry শুধু একবার, যাতে অসীম চক্র না হয়।
৬ · Refresh rotation আর reuse detection
Refresh token rotation-এ প্রতিটি refresh token একবারই চলে। ব্যবহৃত token আবার এলে কিছু গোলমাল আছে, যদিও server জানে না কে আসল। তখন reuse detection পুরো family বন্ধ করে। RFC 9700 (২০২৫) বলে, public client-এর refresh token হয় sender-constrained হবে, নয় rotation ব্যবহার করবে। কেন এই নিয়ম, তা সাইবার নিরাপত্তা কোর্সে আছে। এখানে দেখুন Prisma-তে এর কেন্দ্রের অংশ, log-এর লাইন বাদ দিয়ে:
export async function rotate(raw: string) {
const row = await prisma.refreshToken.findUnique({
where: { tokenHash: sha256(raw) },
include: { family: { include: { user: true } } },
});
if (!row) throw new RefreshError('unknown');
const { family } = row;
if (family.revokedAt) throw new RefreshError('revoked');
if (row.expiresAt < new Date() || family.expiresAt < new Date()) {
throw new RefreshError('expired');
}
const next = newToken(family.id);
const claimed = await prisma.$transaction(async (tx) => {
const c = await tx.refreshToken.updateMany({ // atomic claim
where: { id: row.id, usedAt: null },
data: { usedAt: new Date() },
});
if (c.count === 1) await tx.refreshToken.create({ data: next.data });
return c.count === 1;
});
if (!claimed) {
await prisma.tokenFamily.update({
where: { id: family.id }, data: { revokedAt: new Date() },
});
throw new RefreshError('reuse');
}
return { refresh: next.raw, access: await signAccess(family.user, family.id) };
}মূল লাইনটি updateMany. usedAt খালি কি না দেখে আলাদা করে লিখলে দুটি request একসাথে “খালি” দেখত। এটি পর্ব ৪৩-এর race condition. শর্তসহ একটি UPDATE হলো atomic claim: database নিজে দুজনের মধ্যে একজনকেই জেতায়, তাই count হয় 1 বা 0. Transaction নিশ্চিত করে, দাবি আর নতুন token একসঙ্গে হয়, মাঝপথে crash হলে দুটোর কোনোটিই থাকে না।
Refresh আর logout route cookie ধরে চলে, তাই সেগুলো CSRF-এর আওতায় পড়ে। ল্যাবে দুটি স্তর আছে: SameSite=Strict আর Origin header-এর allowlist. Terminal থেকে অচেনা Origin পাঠালে উত্তর এসেছে 403 bad_origin. পুরো তালিকা পর্ব ৫০-এ।
এবার পুরো ঘটনা, আসল PostgreSQL-এ। একটি ড্রাইভার ফাইল login করে, দুবার refresh করে, তারপর সবচেয়ে পুরোনো cookie আবার পাঠায়।
$ npx tsx src/flow.ts # সত্যিকারের run: Express 5.3.0, Prisma 7.10.1, embedded PostgreSQL
LOGIN 200
…
GET /me 200 {"sub":"1","role":"customer"}
GET /me no token 401 Bearer {"title":"missing_token","status":401}
REFRESH 1 200 cookie changed: true
REFRESH 2 200 cookie changed: true
REFRESH bad Origin 403 {"title":"bad_origin","status":403}
REUSE old token 401 {"title":"refresh_failed","status":401}
NEWEST token after family kill 401 {"title":"refresh_failed","status":401}
old access still works? 200
# API log
auth.login user=1 family=b4c11bbe token=a8bdfac8
auth.refresh ok family=b4c11bbe used=a8bdfac8 new=0583be17
auth.refresh ok family=b4c11bbe used=0583be17 new=8ac02d92
auth.reuse ALERT user=1 family=b4c11bbe token=a8bdfac8 action=family-revoked
auth.refresh denied family=b4c11bbe token=8ac02d92 reason=family-revoked
# database-এর অবস্থা
family b4c11bbe revokedAt=set
id=1 hash=a8bdfac8… hashLen=64 usedAt=set
id=2 hash=0583be17… hashLen=64 usedAt=set
id=3 hash=8ac02d92… hashLen=64 usedAt=null
hashLen=64 মানে SHA-256-এর ৬৪ hex অক্ষর। শেষ লাইনটি খেয়াল করুন: old access still works? 200. Family বন্ধ হলেও হাতে থাকা access token মেয়াদ পর্যন্ত চলে। অংশ ৮-এ এটিই আসল সমস্যা। নিচের ধাপগুলো একই run-এর মান দিয়ে সাজানো।
1row = findUnique(sha256(raw))2if family.revokedAt: 4013c = updateMany(usedAt: null)4if c.count == 0:5 family.revokedAt = now; alert6create(next token)7return new access, new cookie
| a8bdfac8 | |
| 0583be17 | |
| 8ac02d92 | |
| family | |
| access token |
- Login. প্রথম token
a8bdfac8সক্রিয়, family সচল। - প্রথম refresh.
updateManyএকটি সারি পেল,count = 1.a8bdfac8ব্যবহৃত হলো,0583be17তৈরি হলো। - দ্বিতীয় refresh. একইভাবে
0583be17গেল,8ac02d92এল। Family-তে সক্রিয় token একটিই। - পুরোনো token আবার এল।
a8bdfac8ফিরে এসেছে। সারি পাওয়া গেল, family সচল, কিন্তুusedAtআগেই ভরা। তাইcount = 0. - Family বন্ধ। কে আসল, server জানে না। নিরাপদ সিদ্ধান্ত একটাই:
revokedAtবসল, সতর্কবার্তা গেল log-এ, cookie মোছা হলো, উত্তর 401. - সবচেয়ে নতুনটিও আটকে গেল। সৎ অ্যাপ
8ac02d92পাঠাল। সে কখনো ব্যবহৃত হয়নি, তবু 401, কারণ family বন্ধ। ব্যবহারকারীকে আবার login করতে হবে। - যা বন্ধ হয়নি। আগে দেওয়া access token JWT, সে database দেখে না। তাই
GET /meএখনো 200, মেয়াদ শেষ না হওয়া পর্যন্ত।
একই যুক্তি ছোট মডেলে চালিয়ে দেখুন। এখানে Map হলো table, আর এতে শুধু hash জমা থাকে।
// শেখার মডেল: Map = refresh token-এর table, শুধু hash জমা থাকে
const { createHash } = require("node:crypto");
const sha = (s) => createHash("sha256").update(s).digest("hex");
const rows = new Map(); // hash -> { family, used }
const revoked = new Set(); // বাতিল family
let n = 0;
function issue(family) {
// বাস্তবে randomBytes(32); এখানে একই আউটপুটের জন্য সিমুলেটেড
const raw = "rt" + ++n + "-example";
rows.set(sha(raw), { family, used: false });
return raw;
}
function refresh(raw) {
const row = rows.get(sha(raw));
if (!row) return "401 unknown";
if (revoked.has(row.family)) return "401 family revoked";
if (row.used) {
revoked.add(row.family);
return "401 REUSE, family killed: " + row.family;
}
row.used = true;
return "200 next=" + issue(row.family);
}
const a = issue("phone");
const lap = issue("laptop");
console.log("1", refresh(a)); // rt3
console.log("2", refresh("rt3-example")); // rt4
console.log("3", refresh(a)); // পুরোনো copy আবার
console.log("4", refresh("rt4-example")); // সবচেয়ে নতুনটিও বন্ধ
console.log("5", refresh(lap)); // অন্য family অক্ষত
console.log("stored:", [...rows.keys()][0].slice(0, 12), "raw stored?", rows.has(a));প্রথম দুই লাইন স্বাভাবিক: 200 next=rt3-example, 200 next=rt4-example. তৃতীয় লাইনে পুরোনো token: 401 REUSE, family killed: phone. চতুর্থ লাইনে সৎ token-ও 401 family revoked. rt2 দ্বিতীয় device-এর, তাই পঞ্চম লাইনে সে নিরাপদ: 200 next=rt5-example. শেষ লাইন raw stored? false: কাঁচা token জমা নেই, আছে d9d197d8c6e6… ধরনের hash.
দুর্বল নেটওয়ার্ক: Server rotate করল, কিন্তু উত্তরটি পথে হারাল। অ্যাপ পুরোনো cookie নিয়েই আবার চেষ্টা করলে সেটি reuse দেখায়, আর সৎ ব্যবহারকারী logout হন। তাই কিছু identity provider কয়েক সেকেন্ডের grace period রাখে, সাধারণত শুধু ঠিক আগের token-এর জন্য। এটি নিরাপত্তা আর সুবিধার আপস। আমাদের ল্যাবে কোনো grace নেই।
৭ · Browser-এ token কোথায় রাখবেন
এখানে সম্পূর্ণ নিরাপদ জায়গা বলে কিছু নেই, শুধু ভিন্ন ঝুঁকি। সাধারণ শর্ত হলো XSS: পাতায় অন্যের script চললে কী হয়?
| নকশা | XSS হলে | সুবিধা | অসুবিধা |
|---|---|---|---|
Memory + HttpOnly refresh cookie (এই পর্ব) | Refresh token পড়া যায় না। তবে পাতা খোলা থাকা পর্যন্ত script নিজে refresh ডাকতে পারে | দীর্ঘজীবী token বাইরে যায় না | Reload-এ একটি refresh লাগে; CSRF সামলাতে হয় |
localStorage-এ দুই token | Script দুই token পড়ে বাইরে নিতে পারে, পরে অন্য জায়গা থেকেও চলে | কোড সবচেয়ে সহজ; CSRF প্রায় নেই | ক্ষতির সময় আর জায়গা সবচেয়ে বড়; OWASP নিষেধ করে |
| BFF: token server-এ, browser-এ session cookie | Token পড়া যায় না; পাতা খোলা থাকা পর্যন্ত request পাঠানো যায় | ঝুঁকি সবচেয়ে কম | বাড়তি server; নকশা ভারী |
সৎ কথাটি এই: HttpOnly XSS থামায় না। Script fetch('/api/auth/refresh') ডাকলে cookie নিজে যায়, আর উত্তরের access token সে পড়তে পারে। লাভ হলো সে refresh token নিজে হাতে পায় না, তাই ক্ষতি সেই পাতা খোলা থাকার সময়ে সীমিত। XSS-এর আসল প্রতিরোধ XSS আর CSP. localStorage দলের যুক্তিও বাতিল নয়: ছোট অ্যাপ, খুব কড়া CSP আর কম ঝুঁকিতে কেউ কেউ এটি বেছে নেন। শুধু দামটি জেনে নিন।
ল্যাবের cookie: __Secure-rt=…; Path=/api/auth; HttpOnly; Secure; SameSite=Strict. Path ছোট, তাই সাধারণ API request-এ cookie যায় না। পাতা আর API একই site-এ থাকলে এটি সহজ। আলাদা site হলে SameSite=None, credentials: 'include' আর CORS-এর credential নিয়ম লাগে, যা পর্ব ২৬-এ আছে। আমরা Vite proxy দিয়ে /api-কে একই origin রেখেছি। Edge-এ চালিয়ে দেখা ফল:
# সত্যিকারের run, Edge headless, Playwright দিয়ে চালানো
document.cookie = '' localStorage.length = 0
cookie __Secure-rt httpOnly True secure True sameSite Strict path /api/auth valueLen 43
৮ · Logout কেন কঠিন: revocation আর denylist
Session-এ সারি মুছলেই শেষ। JWT যাচাই শুধু স্বাক্ষর আর সময় দেখে, তাই “এটি বাতিল” বলার জায়গা নেই। একে বলে revocation-এর সমস্যা। ল্যাবে logout করে একই access token আবার পাঠিয়েছি। প্রথমবার denylist বন্ধ, দ্বিতীয়বার চালু।
$ npx tsx src/logout-flow.ts # সত্যিকারের run, DENYLIST=off
before logout 200
logout 204
after logout, access 200
after logout, refresh 401
$ npx tsx src/logout-flow.ts # সত্যিকারের run, DENYLIST=on
before logout 200
logout 204
after logout, access 401
after logout, refresh 401
Denylist বন্ধ থাকলে logout-এর পরেও token চলেছে, আর refresh বন্ধ। তাই সবচেয়ে খারাপ ক্ষেত্রে logout-এর সততা access token-এর মেয়াদের সমান, এখানে ৫ মিনিট। দুটি পথ আছে:
- মেনে নেওয়া: বেশিরভাগ অ্যাপে ৫ মিনিট গ্রহণযোগ্য, যদি refresh বন্ধ থাকে।
- Denylist: বাতিল
jtiএকটি store-এ রাখা, প্রতি request-এ দেখা। ল্যাবে এটি একটি Map, যা একটি process-এই সীমিত। একাধিক server-এ পর্ব ৪৪-এর Redis লাগবে, আর TTL হবে token-এর বাকি মেয়াদ।
Denylist-এর আকার ছোট, এটি সুখবর। সারি থাকে শুধু মেয়াদ শেষ পর্যন্ত। সেকেন্ডে ২টি logout (ধরে নেওয়া মান) আর ৩০০ সেকেন্ড মেয়াদে সর্বোচ্চ 2 × 300 = 600টি সারি। দাম আকারে নয়, নির্ভরতায়: প্রতি request-এ একটি lookup আবার ফিরে এল, যা না করাই ছিল JWT-র মূল লাভ। “সব device থেকে বের করা” বা role বদলের জন্য family বন্ধ করা যায়, আর জরুরি হলে key বদলে সবার token একসঙ্গে বাতিল করা যায়। পরেরটিতে সবাইকে আবার login করতে হয়।
৯ · React-এ সংযোগ: memory, fetch wrapper আর single-flight
Access token থাকবে একটি module-এর ভেতরের সাধারণ variable-এ, React state-এ নয়। এতে তা কোনো component tree বা devtools state-এ ছড়ায় না। পুরো auth কথাবার্তা এক জায়গায়, একটি fetch wrapper-এ। নিচের কোড ল্যাবের, শুধু demo-র জন্য রাখা একটি mode সুইচ বাদ দিয়ে দেখানো।
let accessToken: string | null = null;
let refreshing: Promise<string | null> | null = null;
let onSessionLost = () => {};
export const setAccessToken = (t: string | null) => { accessToken = t; };
export const setOnSessionLost = (fn: () => void) => { onSessionLost = fn; };
async function doRefresh(): Promise<string | null> {
const res = await fetch('/api/auth/refresh', { method: 'POST' });
if (!res.ok) { accessToken = null; return null; }
accessToken = (await res.json()).accessToken as string;
return accessToken;
}
export function refreshOnce(): Promise<string | null> {
refreshing ??= doRefresh().finally(() => { refreshing = null; });
return refreshing;
}
export async function api(path: string, init: RequestInit = {}) {
const send = (token: string | null) => {
const headers = new Headers(init.headers);
if (token) headers.set('Authorization', `Bearer ${token}`);
return fetch(path, { ...init, headers });
};
const sent = accessToken;
const res = await send(sent);
if (res.status !== 401) return res;
// অন্য কেউ এর মধ্যে নতুন token এনে থাকলে refresh লাগবে না
const fresh = accessToken && accessToken !== sent ? accessToken : await refreshOnce();
if (!fresh) { onSessionLost(); return res; }
return send(fresh); // retry শুধু একবার
}কেন্দ্রের লাইন refreshing ??= …. চলমান refresh থাকলে নতুন refresh শুরু হয় না, সবাই একই Promise-এর জন্য অপেক্ষা করে। এটিই single-flight, যা আপনি পর্ব ৪৪-এর cache stampede-এ দেখেছেন। sent আর fresh-এর লাইন একটি দেরিতে আসা 401 সামলায়: সেই request পুরোনো token-এ গিয়েছিল, কিন্তু এর মধ্যে নতুন token চলে এসেছে। 401-এর পরে POST-ও আবার পাঠানো নিরাপদ, কারণ যাচাইয়ে আটকে যাওয়া request কোনো কাজ করেনি।
1const res = await send(sent)2if (res.status !== 401) return res3refreshing ??= doRefresh()4.finally(() => refreshing = null)5return send(fresh)
| accessToken | |
| refreshing | |
| /api/me | |
| /api/orders | |
| /api/cart |
- Click.
Promise.allতিনটিapi()একসাথে চালাল। তিনটিই একই পুরোনো token নিয়ে গেল। - তিনটি 401. ৩২ ms-এ তিনটি উত্তরই 401. Token-এর মেয়াদ শেষ, এতে অবাক হওয়ার কিছু নেই।
- প্রথমজন refresh শুরু করল।
/api/meদেখলrefreshingnull. তাইdoRefresh()শুরু হলো, আর তার Promise জমা হলো। - বাকি দুজন যোগ দিল।
/api/ordersআর/api/cartদেখল Promise আছে। নতুন network call হলো না, দুজনই একই P-র অপেক্ষায়। - Refresh শেষ। ৪৭ ms-এ 200.
accessTokenনতুন হলো, তিনজন একসঙ্গে জাগল, আরfinallyrefreshingখালি করল। - তিনটি retry. ৫০ থেকে ৫২ ms-এ তিনটিই 200. Server দেখল একটিমাত্র refresh, ব্যবহারকারী দেখলেন শুধু একটি ছোট দেরি।
Single-flight না থাকলে কী হয়? নিচের মডেলে একই server, দুইভাবে।
// শেখার মডেল: একটি server, তিনটি একসাথে চলা request, মেয়াদ শেষ token
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function scenario(useSingleFlight) {
let cookie = "rt0", valid = "rt0", family = "alive";
let calls = 0, inFlight = null;
async function serverRefresh(sent) {
calls++;
await sleep(10);
if (family === "dead" || sent !== valid) { family = "dead"; return null; }
valid = "rt" + calls;
cookie = valid;
return "new-access-" + calls;
}
const refreshOnce = () =>
(inFlight ??= serverRefresh(cookie).finally(() => { inFlight = null; }));
async function api() {
await sleep(2); // প্রথম চেষ্টা: 401
const t = await (useSingleFlight ? refreshOnce() : serverRefresh(cookie));
return t ? 200 : 401; // নতুন token পেলে retry সফল
}
const out = await Promise.all([api(), api(), api()]);
console.log(useSingleFlight ? "single-flight:" : "naive :",
"refresh calls =", calls, "| results =", out.join(" "), "| family =", family);
}
scenario(false).then(() => scenario(true));naive : refresh calls = 3 | results = 200 401 401 | family = dead. তিনটি refresh একই cookie নিয়ে গেল। প্রথমটি জিতল, বাকি দুটি server-এর চোখে reuse, তাই family শেষ। single-flight: refresh calls = 1 | results = 200 200 200 | family = alive. Rotation আর reuse detection যত কড়া, client-এর শৃঙ্খলা তত জরুরি। মডেলটি সিমুলেটেড, তাই এবার আসল browser দেখুন।
ল্যাবে React 19.3.1 আর Vite 8.3.5, API-তে শুধু demo-র জন্য ACCESS_TTL=3s. ৫ সেকেন্ড ছাড় মিলিয়ে token প্রায় ৮ সেকেন্ড চলে, তাই Playwright ৯ সেকেন্ড অপেক্ষা করে click করেছে।
# সত্যিকারের run, Edge headless, একটি local run-এর সময়
=== single-flight ===
32 ms GET /api/me 401
32 ms GET /api/orders 401
32 ms GET /api/cart 401
47 ms POST /api/auth/refresh 200
50 ms GET /api/me 200
52 ms GET /api/cart 200
52 ms GET /api/orders 200
status: logged-in 1
=== naive (single-flight ছাড়া) ===
21 ms GET /api/me 401
22 ms GET /api/orders 401
23 ms GET /api/cart 401
31 ms POST /api/auth/refresh 200
36 ms GET /api/me 200
53 ms POST /api/auth/refresh 401
53 ms POST /api/auth/refresh 401
status: logged-out
# API log, naive
auth.refresh ok family=7eb83ce1 used=4cba9a11 new=4b66b0a7
auth.reuse ALERT user=1 family=7eb83ce1 token=4cba9a11 action=family-revoked
auth.reuse ALERT user=1 family=7eb83ce1 token=4cba9a11 action=family-revoked
কোনো চোর নেই, তবু নিরাপত্তা-সতর্কবার্তা, আর ব্যবহারকারী logout. তাই production-এ হঠাৎ reuse alert বেড়ে গেলে আগে নিজের client-এর কোড সন্দেহ করুন।
Reload, StrictMode আর একাধিক tab
Memory-র token reload-এ হারায়। তাই অ্যাপ খোলার সময় AuthProvider একটি silent refresh করে। পর্ব ১৭-এর Context আর পর্ব ১৩-এর cleanup-এর চেনা গড়ন:
useEffect(() => {
setOnSessionLost(() => setUser(null));
let alive = true;
(async () => {
if (await refreshOnce()) { // reload-এর পরে নীরব refresh
const res = await api('/api/me');
if (alive && res.ok) setUser(await res.json());
}
if (alive) setReady(true);
})();
return () => { alive = false; };
}, []);Development-এ StrictMode effect দুবার চালায়। Single-flight না থাকলে এই একটি reload-ই ব্যবহারকারীকে logout করে। আসল run-এর তুলনা:
# সত্যিকারের run, Vite dev, StrictMode চালু, reload
=== naive ===
47 ms POST /api/auth/refresh 200
49 ms GET /api/me 200
66 ms POST /api/auth/refresh 401
status: logged-out
=== single-flight ===
46 ms POST /api/auth/refresh 200
49 ms GET /api/me 200
50 ms GET /api/me 200
status: logged-in 1
Single-flight একটি tab-এর ভেতরে কাজ করে, কারণ variable প্রতিটি tab-এর আলাদা। দুটি tab একসাথে refresh করলে কী হয়? একই cookie, তাই একই সমস্যা। ল্যাবে দুই tab প্রায় একসাথে click করে দুবার চালিয়েছি, দুবারই tab B বের হয়ে গেছে। সমাধান Web Locks API, যা একই origin-এর tab-দের মধ্যে একবারে একজনকে lock দেয়। HTTPS-এ (আর localhost-এ) চলে, ২০২২ সাল থেকে সব বড় browser-এ আছে।
const refreshLocked = () =>
navigator.locks.request('bazarghar-refresh', doRefresh);
export function refreshOnce() {
refreshing ??= refreshLocked().finally(() => { refreshing = null; });
return refreshing;
}# সত্যিকারের run, Edge, দুই tab (A, B), লক ছাড়া
…
19 ms A POST /api/auth/refresh 200
24 ms B POST /api/auth/refresh 401
A: logged-in 1 | B: logged-out
# Web Locks সহ
…
18 ms A POST /api/auth/refresh 200
26 ms B POST /api/auth/refresh 200
…
A: logged-in 1 | B: logged-in 1
Lock থাকলে B অপেক্ষা করে, আর পাঠায় A-র রেখে যাওয়া নতুন cookie. তাই সেটি reuse নয়। পর্ব ১৯-এর TanStack Query ব্যবহার করলে তার queryFn-এ এই api()-ই ডাকুন। Logout-এর খবর অন্য tab-এ দ্রুত পৌঁছাতে BroadcastChannel কাজে লাগে।
মোবাইল অ্যাপ: সেখানে HttpOnly cookie-র ভরসা নেই, XSS-এর ধরনও আলাদা। Refresh token সাধারণত request body-তে যায়, আর থাকে OS-এর secure storage-এ, যেমন iOS Keychain বা Android Keystore-ভিত্তিক storage, সাধারণ preference ফাইলে নয়। তাই API-কে দুই ধরনের client মেনে নিতে হয়। Rotation, family আর reuse detection একই থাকে। Client-এ single-flight লাগে একইভাবে। বাইরের login থাকলে পর্ব ৪৭-এর OAuth পথ দেখুন।
আরও গভীরে: Rotation চুরি ধরে, ঠেকায় না। RFC 9700 আরেকটি পথ বলে, sender-constrained token, যেখানে token একটি client-এর key-তে বাঁধা থাকে। DPoP (RFC 9449) এর একটি উদাহরণ: প্রতি request-এ client নিজের private key দিয়ে একটি প্রমাণ পাঠায়, তাই token চুরি হলেও একা সে অচল। JWT-র সেরা অনুশীলন আছে RFC 8725-এ, আর access token-এর প্রোফাইল RFC 9068-এ।
পরীক্ষা ও ইন্টারভিউতে:
- JWT encrypted কি? সাধারণ JWT স্বাক্ষর করা, encrypted নয়; payload যে কেউ পড়তে পারে।
- Refresh token কেন লাগে? ছোট মেয়াদের access token রেখেও বারবার login এড়াতে, আর server-এ বাতিলের একটি জায়গা রাখতে।
- Refresh token hash করে রাখা হয় কেন? Database ফাঁস হলেও চালু token যাতে না পাওয়া যায়।
- Logout-এর পরে access token চলে কেন? যাচাই database দেখে না; denylist বা ছোট মেয়াদ লাগে।
- Single-flight refresh কী? একসাথে অনেক 401 এলেও refresh একবার, বাকিরা একই Promise-এর অপেক্ষায়।
১০ · সাধারণ ভুল আর AI-এর যুগে JWT কোড
AI খুব দ্রুত auth কোড লিখে দেয়, আর সেটি প্রায়ই চলে। সমস্যা হলো “চলে” আর “নিরাপদ” এক কথা নয়। নিচের ভুলগুলো মানুষের কোডেও দেখা যায়, AI-এর কোডেও।
| ভুল | ফল | ঠিক পথ |
|---|---|---|
decodeJwt বা jwt.decode দিয়ে “যাচাই” | স্বাক্ষর দেখাই হয় না | jwtVerify, algorithm তালিকাসহ |
algorithms, issuer, audience বাদ | অন্য অ্যাপের token চলে | তিনটিই প্রতি verify-তে |
Secret কোডে, বা 'secret' ধরনের দুর্বল মান | ফাঁস হলে সব token জাল করা যায় | Environment variable, ৩২ byte এলোমেলো |
| Access token-এর মেয়াদ ৩০ দিন | Logout অর্থহীন | কয়েক মিনিট, সঙ্গে refresh |
| Refresh token কাঁচা অবস্থায় database-এ | Database ফাঁসে সব session হাতছাড়া | SHA-256 hash |
| Rotation আছে, atomic claim নেই | দুটি request একসাথে জেতে | শর্তসহ UPDATE |
| 401-এ অসীম retry, বা single-flight নেই | Request-এর ঝড়, নিজের হাতে logout | Retry একবার, refresh একবার |
Token-এর sub না দেখে request body-র userId মানা | একজন আরেকজনের ডেটা দেখে | পরিচয় শুধু যাচাই করা token থেকে (পর্ব ৪৮) |
পুরোনো পরামর্শ অনুসরণ, যেমন jsonwebtoken-এর পুরোনো version | আগের নিরাপত্তা-ত্রুটি থেকে যায় | Version দেখা, লক ফাইল, npm audit |
AI-কে কোড চাওয়ার সময় version আর নিয়ম বলে দিন, আর test চান। তাহলে যাচাই সহজ হয়।
AI-এর লেখা auth কোড হাতে পেলে
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।
প্র ০১Refresh token যদি database-এই থাকে, তাহলে JWT নিয়ে লাভ কী থাকল? Session নিলেই তো হতো?
প্রশ্নটি ন্যায্য, আর অনেকের কাছে উত্তরটি “হ্যাঁ, session নিলেই হতো”। JWT জনপ্রিয় হওয়ার পর ২০১৬ সালের দিকে “JWT-কে session হিসেবে ব্যবহার করবেন না” ধরনের লেখা ব্যাপকভাবে আলোচনায় আসে, আর সেই বিতর্ক আজও চলে। তাই হিসাব করে দেখা ভালো।
লাভ আসে database ডাকার সংখ্যায়। ধরুন একজন ব্যবহারকারী মিনিটে ২০টি request পাঠান। Session store-এ ঘণ্টায় ১,২০০টি lookup. ৫ মিনিটের JWT-তে ঘণ্টায় ১২টি refresh, মানে ১০০ ভাগের এক ভাগ। Redis-এর একটি lookup খুব সস্তা, তাই ছোট অ্যাপে এই লাভ চোখেই পড়বে না। কিন্তু দশটি service থাকলে, বা store অন্য অঞ্চলে থাকলে, প্রতি request-এ কেন্দ্রীয় store ডাকা বাধা হয়ে দাঁড়ায়। তখন লাভটি আসল। আরেকটি লাভ নির্ভরতায়: store কিছুক্ষণ বিকল হলেও চালু access token দিয়ে পড়ার কাজ চলতে থাকে।
দাম কী? Token বড়, খারাপ যাচাইয়ের ঝুঁকি, key ব্যবস্থাপনা, rotation-এর কোড, client-এর single-flight আর সীমিত logout. এই পর্বের কোডের পরিমাণই দাম বোঝায়। Session-এ এর বড় অংশ লাগে না।
মূল উপলব্ধি: JWT কোনো আপগ্রেড নয়, একটি আপস। প্রশ্নটি “কোনটি আধুনিক” নয়, প্রশ্ন হলো আপনার store-এর চাপ কি revocation-এর দামের চেয়ে বেশি। একটি domain-এর সাধারণ অ্যাপে উত্তর সাধারণত “না”।
প্র ০২Reuse detection সৎ ব্যবহারকারীকেও বের করে দেয়। দুর্বল মোবাইল নেটওয়ার্কে এটি কি মেনে নেওয়া উচিত?
ঘটনাটি ভাবুন। গ্রামের দিকে চলন্ত বাসে দুর্বল 4G. অ্যাপ refresh পাঠাল, server rotate করল, উত্তরটি পথে হারাল। অ্যাপের কাছে এখনো পুরোনো token. সে আবার চেষ্টা করল। Server দেখল ব্যবহৃত token ফিরে এসেছে। এটি reuse, তাই পুরো family শেষ। কোনো চোর নেই, তবু ব্যবহারকারী logout, আর log-এ নিরাপত্তা-সতর্কবার্তা।
এই বাস্তবতায় বেশ কিছু identity provider একটি ছোট grace period বা reuse interval দেয়। নিয়মটি সাধারণত এমন: rotate হওয়ার পর কয়েক সেকেন্ড ঠিক আগের token গ্রহণ করা হয়, আর পুরোনোগুলো নয়। কোথাও এটি default-এ চালু, কোথাও বন্ধ। মানে শিল্পেও একমত হওয়া কোনো মান নেই।
এর দাম কী? যে কয়েক সেকেন্ডের জানালায় চোর আর আসল client দুজনই চলতে পারে, সেখানে চুরি ধরা পড়বে না। তবে জানালাটি ছোট, আর চোরকে ঠিক সেই সময়ে পুরোনো token পেতে হবে। ঝুঁকির কাজে, যেমন আর্থিক অ্যাপে, grace না রেখে বরং ব্যবহারকারীকে আবার login করানো ভালো হতে পারে। বাজারের কেনাকাটার অ্যাপে কড়া নিয়ম গ্রাহক হারাতে পারে।
আরেকটি সূক্ষ্ম বিষয় হলো পরিমাপ। Reuse alert-এর সংখ্যা দেখুন। এই পর্বের browser run-এ দেখেছেন, single-flight-এর ভুলেও একই alert আসে। Alert-এর বেশিরভাগ যদি বাগ হয়, তাহলে আসল চুরির সংকেত শব্দের ভিড়ে হারাবে। তাই আগে client ঠিক করতে হয়।
মূল উপলব্ধি: নিরাপত্তার নিয়ম বাস্তব নেটওয়ার্কের ওপর চলে। ঝুঁকি, ব্যবহারকারী আর ব্যর্থতার ধরন মেপে সিদ্ধান্ত নিন, আর সেই সিদ্ধান্ত লিখে রাখুন।
প্র ০৩Library-র default তো এখন নিরাপদ। তাহলে প্রতিবার algorithms লেখা আর regression test রাখা কি অতিরিক্ত নয়?
Default-এর ওপর ভরসার ইতিহাসটি দেখুন। ২০১৫ সালের গবেষণার পর library-গুলো অনেক কড়া হলো, তবু সমস্যা পুরো শেষ হয়নি। উদাহরণ হিসেবে jsonwebtoken-এর ২০২২ সালের একটি advisory (CVE-2022-23540) দেখা যায়। প্রকাশিত বিবরণ অনুযায়ী, ৮.৫.১ পর্যন্ত version-এ algorithm তালিকা না দিলে, আর key ফাঁকা থাকলে, স্বাক্ষরহীন token পার হতে পারত। ৯.০.০ version সেই default বদলায়। লক্ষ করুন, যাঁরা নিজে থেকে algorithm তালিকা দিতেন, এই শর্ত তাঁদের ক্ষেত্রে খাটত না।
দ্বিতীয় বিষয় হলো default নিজে বদলায়। Major version-এ, library বদলালে, বা AI যখন অন্য library-র কোড বসিয়ে দেয়। আজকের নিরাপদ default আগামীকাল আপনার প্রকল্পে না-ও থাকতে পারে। স্পষ্ট option কোডে থাকলে কোড পড়েই নিয়ম বোঝা যায়। Reviewer-কে library-র ভেতরে যেতে হয় না।
তৃতীয় বিষয় test. “ভালো token গৃহীত হলো” সব verifier-এই পাস করে, এমনকি যেটি স্বাক্ষর দেখেই না। নিরাপত্তার test মানে খারাপ ইনপুট বাতিল হওয়ার প্রমাণ। এই পর্বের দশ লাইনের তালিকা তাই তৈরি করতে সময় লাগে কম, কিন্তু library আপগ্রেডের দিনে সে পুরো শৃঙ্খলা পাহারা দেয়।
দাম খুব কম: একটি option আর একটি test ফাইল। না রাখলে সম্ভাব্য দাম সব ব্যবহারকারীর পরিচয়।
মূল উপলব্ধি: নিরাপত্তার নিয়ম লুকানো default-এ রাখবেন না। কোডে স্পষ্ট লিখুন, test দিয়ে পাহারা দিন। একটি লাইনের বাড়তি লেখা এখানে সবচেয়ে সস্তা বিমা।
প্র ০৪XSS হলে memory-র token-ও তো নিরাপদ নয়। তাহলে localStorage ছেড়ে এত কষ্ট করার মানে কী?
দুই পক্ষের কথাই শুনুন। localStorage পক্ষ বলেন: XSS হলে হামলাকারী আপনার পাতাতেই কোড চালাচ্ছে। সে API ডাকতে পারে, refresh ডাকতে পারে, ফর্ম বদলাতে পারে। তাহলে token কোথায় থাকল, তাতে কী এসে যায়? আর cookie নিলে CSRF-এর নতুন ঝামেলা। যুক্তিটি ফেলে দেওয়ার মতো নয়। XSS থাকলে কোনো জায়গাই পুরো রক্ষা করে না।
অন্য পক্ষ ক্ষতির আকার দেখেন। পার্থক্য হলো হামলা কতক্ষণ আর কোথা থেকে চলে। HttpOnly cookie-তে refresh token থাকলে script সেটি বাইরে নিতে পারে না। ব্যবহারকারী tab বন্ধ করলে হামলাও অনেকটা থামে। localStorage-এ refresh token থাকলে script সেটি বাইরে পাঠাতে পারে, তারপর অন্য computer থেকে, পরে, মেয়াদ পর্যন্ত চলতে পারে। Rotation এখানে কিছুটা সাহায্য করে, তবে সেটি ক্ষতি হওয়ার পরের ব্যবস্থা।
আধুনিক অ্যাপে শত শত npm dependency, analytics আর chat widget চলে। পাতায় একটি খারাপ script ঢোকা তাই নিছক তত্ত্ব নয়। OWASP-এর নির্দেশিকাও web storage-এ এই ধরনের token রাখতে নিষেধ করে। তবে এখানে “এত কষ্ট” বাস্তব: reload-এ refresh, CSRF-এর স্তর, CORS-এর জটিলতা। ঝুঁকি বেশি হলে BFF নকশা আরও এগিয়ে যায়, token আর browser-এ আসেই না।
মূল উপলব্ধি: “নিরাপদ, না অনিরাপদ” নয়, প্রশ্ন “ক্ষতি কতটা ছড়াতে পারে”। XSS প্রতিরোধ প্রথম স্তর। Token-এর জায়গা ঠিক করে ক্ষতির সীমা, আর সেই সীমার দাম আপনি কোড দিয়ে দেন।
প্র ০৫বাজারঘর বড় হয়ে তিনটি service হলো। HS256 থেকে Ed25519-এ যাবেন কীভাবে, কাউকে logout না করে?
আগে কারণটি পরিষ্কার করি। HS256-এ যিনি যাচাই করেন, তিনি স্বাক্ষরও করতে পারেন। তিনটি service-কে একই secret দিলে তিনটির যেকোনো একটি ফাঁস হলেই সব ব্যবহারকারীর token জাল করা সম্ভব। Public-key স্বাক্ষরে শুধু auth service-এর কাছে private key থাকে, বাকিরা শুধু যাচাই করে।
বদলটি হবে ধাপে ধাপে। প্রথম ধাপ: auth service একটি Ed25519 key বানায়, আর JWKS-এ public key প্রকাশ করে। দ্বিতীয় ধাপ: verifier-গুলো দুই রকম token মেনে নেওয়ার মতো করে আপডেট হয়। এখানে সতর্কতা জরুরি। একই verify call-এ HMAC আর public-key মেশানো চলবে না। Token-এর alg বা kid দেখে দুটি আলাদা, স্থির পথের একটি বাছুন, আর প্রতিটি পথে একটিই algorithm আর একটিই ধরনের key থাকবে। তৃতীয় ধাপ: auth service নতুন access token Ed25519-এ দিতে শুরু করে। চতুর্থ ধাপ: পুরোনো access token-এর মেয়াদ শেষ হওয়া পর্যন্ত অপেক্ষা, এখানে ৫ মিনিট আর ছাড়। পঞ্চম ধাপ: HS256-এর পথ আর secret সরানো।
এখানে ছোট মেয়াদের access token-এর আরেকটি লাভ দেখা যায়। বদলের জানালা মাত্র কয়েক মিনিট, আর refresh token opaque বলে সেগুলোতে হাতই দিতে হয় না। ব্যবহারকারী পরের refresh-এ নীরবে নতুন ধরনের token পেয়ে যান। ল্যাবের JWKS run-এ একই ক্রম দেখেছেন: আগে প্রকাশ, পরে স্বাক্ষর, শেষে সরানো। JWKS-এর cache থাকে বলে প্রকাশের পরে স্বাক্ষর শুরুর আগে একটু সময় রাখাই নিরাপদ।
মূল উপলব্ধি: ভালো নকশায় key বদল একটি সাধারণ, মহড়া দেওয়া কাজ, বিপদের দিনের প্রথম পরীক্ষা নয়। ক্রমটি মনে রাখুন: যোগ করুন, ব্যবহার করুন, তারপর সরান।
অনুশীলন
হিসাব, কোড-সমালোচনা আর ফল অনুমান, তারপর নিজের হাতে চালানো।
-
হিসাব: ৩০,০০০ সক্রিয় ব্যবহারকারী, access token-এর মেয়াদ ১০ মিনিট। (ক) সেকেন্ডে গড়ে কতটি refresh? (খ) সেকেন্ডে ৩টি logout হলে denylist-এ সর্বোচ্চ কতটি সারি? সব মান ধরে নেওয়া।
(ক) ১০ মিনিট = ৬০০ সেকেন্ড।
30000 / 600 = 50টি refresh প্রতি সেকেন্ডে।(খ) সারি থাকে শুধু token-এর বাকি মেয়াদ পর্যন্ত, সর্বোচ্চ ৬০০ সেকেন্ড।
3 × 600 = 1800টি সারি। ছাড় ৫ সেকেন্ড ধরলে3 × 605 = 1815টি, তাই TTL-এ ছাড়ও যোগ করতে হয়। -
কোড সমালোচনা: AI এই কোড দিয়েছে। অন্তত পাঁচটি সমস্যা ধরুন:
const u = jwt.decode(req.headers.authorization), payload-এ{ id, email, passwordHash }, secret'secret123'কোডে লেখা, মেয়াদ'30d', আর React-এlocalStorage.setItem('token', t).decodeস্বাক্ষর দেখে না, তাই যে কেউ যেকোনো পরিচয় লিখে পাঠাতে পারে। সবচেয়ে গুরুতর ভুল এটি।Bearerঅংশ আলাদা করা হয়নি, আর algorithm,iss,aud-এর কোনো যাচাই নেই।passwordHashpayload-এ, মানে যে কেউ পড়তে পারে। Email-ও অপ্রয়োজনীয়।- দুর্বল secret কোডে লেখা। Environment variable থেকে ৩২ byte এলোমেলো মান লাগবে।
- ৩০ দিনের একক token বাতিল করার কোনো পথ রাখে না। ছোট access আর rotation করা refresh লাগবে।
localStorage-এ দীর্ঘজীবী token হলে XSS-এর ক্ষতি সবচেয়ে বড়।
-
ফল অনুমান: Rotation আর reuse detection চালু। Login-এ
r1পেলেন। এরপর ক্রমেrefresh(r1)দিলr2,refresh(r2)দিলr3. এবারrefresh(r2), তারপরrefresh(r3)ডাকলে দুটি উত্তর কী? শেষে family-র অবস্থা কী?refresh(r2):r2আগেই ব্যবহৃত, তাই 401, reuse ধরা পড়ল, family বন্ধ।refresh(r3):r3ব্যবহার হয়নি, তবু 401, কারণ family বন্ধ। শেষে familyrevoked, তিনটি token-এর কোনোটিই চলে না, আর ব্যবহারকারীকে আবার login করতে হবে। আগে দেওয়া access token তার মেয়াদ পর্যন্ত চলতে পারে। অংশ ৬-এরrotation-model.js-এ লাইনগুলো বদলে মিলিয়ে নিন। -
হাতে চালান: অংশ ২-এর
jwt-by-hand.js-এverify-তে একটি সিমুলেটেডnowআরexp-এর পরীক্ষা যোগ করুন, যাতেnow = 1793592300হলে ফল হয়REJECT exp. কোন লাইনের পরে পরীক্ষাটি বসাবেন, আর কেন?স্বাক্ষর মেলার পরে,
return "ACCEPT …"-এর ঠিক আগে:const claims = JSON.parse(text(body)); if (claims.exp <= now) return "REJECT exp";স্বাক্ষরের আগে claim পড়লে যাচাই না হওয়া ডেটার ওপর সিদ্ধান্ত হতো।
exp <= nowবলে ঠিক1793592300-এ token মৃত, যা jose-এর শর্তের সঙ্গেও মেলে, ছাড় শূন্য ধরে।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমমেয়াদ শেষ token নিয়ে একটি request-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- React পুরোনো access token নিয়ে API ডাকে
- API
ERR_JWT_EXPIREDপেয়ে 401 দেয় - Fetch wrapper একটি refresh শুরু করে, বাকিরা সেটির অপেক্ষায় থাকে
- Server পুরোনো refresh token ব্যবহৃত চিহ্নিত করে, নতুনটি বানায়
- নতুন access token memory-তে বসে, নতুন cookie browser-এ
- আগের request নতুন token নিয়ে একবার retry করে
একটি সাধারণ HS256 JWT-র payload-এ কোন তথ্য রাখা নিরাপদ নয়?
- ব্যবহারকারীর id,
subহিসেবে - ব্যবহারকারীর NID নম্বর আর password hash
- মেয়াদ শেষের সময়,
exp - Token-এর নিজের ID,
jti
JWT verify করার সময় algorithms: ['HS256'] লেখার মূল কারণ কী?
- যাচাই দ্রুত করা
- Token ছোট করা
- Token-এর header-এর বদলে verifier নিজেই ঠিক করবে কোন algorithm চলবে
- Payload encrypt করা
Refresh token database-এ কাঁচা অবস্থায় না রেখে SHA-256 hash হিসেবে রাখার কারণ কী, আর এখানে bcrypt-এর মতো ধীর hash সাধারণত লাগে না কেন?
- Database ফাঁস হলেও চালু token পাওয়া যায় না; মানটি ২৫৬ bit এলোমেলো, তাই অনুমান করা যায় না
- Hash করলে token ছোট হয়, তাই জায়গা বাঁচে
- Hash থেকে পরে আসল token ফেরত আনা যায়
- Hash থাকলে rotation-এর দরকার হয় না
Rotation আর reuse detection চালু থাকা একটি React অ্যাপে তিনটি একসাথে চলা request মেয়াদ শেষ token-এ 401 পেল। প্রতিটি request নিজে আলাদা refresh করলে সবচেয়ে সম্ভাব্য ফল কী?
- তিনটি refresh-ই সফল, শুধু একটু দেরি
- Server বাড়তি দুটি নীরবে উপেক্ষা করে, কোনো ক্ষতি নেই
- Access token-এর স্বাক্ষর ভেঙে যায়
- একটি refresh জেতে, বাকি দুটি reuse হিসেবে ধরা পড়ে, family বন্ধ হয়ে ব্যবহারকারী logout হন
৫ মিনিটের JWT access token ব্যবহারকারী logout করলেন, refresh token-ও server-এ বাতিল হলো, denylist নেই। পরের ১ মিনিটের মধ্যে একই access token পাঠালে কী হবে?
- 401, কারণ refresh token বাতিল
- 200, কারণ যাচাই শুধু স্বাক্ষর আর সময় দেখে
- Token নিজে থেকে মুছে যায়
- 403, কারণ স্বাক্ষর বদলে গেছে
Memory-তে access token আর HttpOnly cookie-তে refresh token রাখা অ্যাপে XSS হলে কোন কথাটি সবচেয়ে সঠিক?
- HttpOnly থাকায় XSS পুরো অকেজো
- Script refresh token পড়ে বাইরে পাঠাতে পারে
- Script নিজে refresh ডেকে access token পেতে পারে, তবে refresh token বাইরে নিতে পারে না
- Memory-র কারণে কোনো token-এর কাছেই script পৌঁছাতে পারে না
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- JWT একটি আপস: আলাদা API, মোবাইল আর অনেক service-এ মানায়, সাধারণ web app-এ session সহজ।
- Payload encoded, গোপন নয়। Verify-তে
algorithms,issuer,audienceসবসময়, আর ছাড় সেকেন্ডের ঘরে। - ছোট access, লম্বা refresh: Refresh token-এর hash database-এ, atomic claim, আর reuse হলে পুরো family বন্ধ।
- Memory +
HttpOnlycookie ক্ষতির সীমা কমায়, XSS থামায় না। Logout-এর সততা access token-এর মেয়াদের সমান, যদি না denylist থাকে। - React-এ refresh একবার, retry একবার: tab-এর ভেতরে single-flight, tab-দের মধ্যে Web Locks.
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৪৭ · Social login — OAuth flow বাস্তবে আর auth libraryপরবর্তী পর্ববাইরের login provider-এর পথ, লেখার সময়ে তৈরি হচ্ছে।
- ফুল-স্ট্যাক ওয়েব ফ্রেমওয়ার্ক · কোর্স হোম৯৩টি পর্বপুরো কোর্সের রোডম্যাপ।
- Session আর token — cookie, JWT আর নিরাপদ logoutতত্ত্বJWT, cookie flag আর rotation-এর নিরাপত্তার যুক্তি।
- OAuth আর OIDCগভীরে যানToken যখন বাইরের issuer থেকে আসে।
- System Design: session আর stateপাশের ট্র্যাকবড় মাপে session store কীভাবে চলে।