Authentication — সেশন কুকি, JWT, আর OAuth2-এর যুদ্ধক্ষেত্র
Authentication: Session Cookies, JWT, and OAuth2
Password যাচাইয়ের পর সার্ভার একটা opaque session ID (stateful, DB-lookup লাগে, instant revoke) অথবা একটা self-contained, signed JWT (stateless, DB-lookup লাগে না, revoke কঠিন) ইস্যু করে — আর alg:none-এর মতো একটা signature-check বাদ পড়া bug পুরো মডেলটাই ভেঙে দিতে পারে, যেখানে OAuth2/OIDC এই দুই primitive-এর ওপর দাঁড়িয়ে delegated authorization আর identity সমাধান করে।
আগে এটা বুঝি
আগের লেসনে password store করার নিরাপদ উপায় দেখা হয়েছে — salted hash, memory-hard KDF, সব ঠিকঠাক। কিন্তু একটা প্রশ্ন সেখানে ইচ্ছা করেই ফেলে রাখা হয়েছিল: password verify হওয়ার পরে কী? User প্রতিটা পরের request-এ তো আর username-password পাঠাবে না — সেটা হাস্যকর রকমের ধীর, আর প্রতি request-এ password network-এ পাঠানো মানে exposure-এর সুযোগ বাড়ানো। দরকার এমন একটা প্রক্রিয়া যেটা একবার login-এর প্রমাণকে পরবর্তী প্রতিটা request-এ বহনযোগ্য করে তোলে।
এই লেসনের নাম authentication, কিন্তু মূল বিষয়টা আসলে narrow: password verify হয়ে গেছে ধরে নিয়ে, “তুমি কে” এই তথ্যটা কীভাবে request থেকে request-এ টিকিয়ে রাখা হয়। HTTP নিজে amnesiac — প্রতিটা request স্বাধীন, আগের কোনো request মনে রাখে না। এই amnesia-র ওপর “মনে রাখা”-র একটা স্তর বসাতে হবে, আর সেই স্তর বসানোর দুইটা মৌলিকভাবে ভিন্ন দর্শন আছে:
দর্শন ১ — stateful: সার্ভার নিজে মনে রাখে, client-কে শুধু একটা রেফারেন্স-নম্বর দেয়
দর্শন ২ — stateless: সার্ভার কিছুই মনে রাখে না, client নিজেই একটা স্বাক্ষরিত প্রমাণ বহন করেপ্রথমটার নাম session cookie, দ্বিতীয়টার নাম JWT (JSON Web Token)। দুইটাই আজ সমান-জনপ্রিয়, দুইটারই নিজস্ব ব্যর্থতার প্যাটার্ন আছে, আর দুইটার মধ্যে বাছাই করাটা কোনো “কোনটা ভালো” প্রশ্ন না — কোন trade-off আপনার সিস্টেমের জন্য সহনীয়, সেই প্রশ্ন। আজকের লেসন দুইটাকেই হাতে-কলমে চিনবে, আর শেষে দেখবে কীভাবে OAuth2 এই authentication-primitive-এর ওপর দাঁড়িয়ে একটা সম্পূর্ণ ভিন্ন সমস্যা (delegated authorization) সমাধান করে।
মূল ধারণা
Session cookie — সার্ভার মনে রাখে, client শুধু একটা রেফারেন্স বহন করে
Session-cookie মডেল নিরস্ত্রভাবে সহজ, চারটা ধাপে:
১. User সঠিক username+password পাঠায়
২. সার্ভার একটা random, unguessable, opaque session ID বানায়
(যেমন 32-byte cryptographically-random string — password বা user-id থেকে derive করা নয়)
৩. সার্ভার সেই session ID-কে key বানিয়ে session state (user-id, login-time,
permissions...) একটা সার্ভার-সাইড store-এ (Redis, DB, in-memory) রাখে
৪. সার্ভার client-কে সেই ID-টাই cookie হিসেবে পাঠায় — client-এর কাছে state-এর
একটা বর্ণও নেই, শুধু একটা রেফারেন্স-নম্বরগুরুত্বপূর্ণ কথাটা তৃতীয় লাইনে: session state client-এ কখনো যায় না। Client শুধু একটা অস্বচ্ছ (opaque) ID বহন করে — সেই ID দেখে কিছুই বোঝা যায় না, এমনকি এটা যে “user-4471”-এর session সেটাও client অনুমান করতে পারে না। প্রতিটা পরের request-এ browser স্বয়ংক্রিয়ভাবে Cookie: session=<id> হেডার যোগ করে, সার্ভার সেই ID দিয়ে store-এ lookup করে state ফেরত পায়। এই কারণেই সার্ভার-সাইড session instant revoke করা যায় — store থেকে entry মুছে দিলেই, সেই ID পরের request-এ আর কিছুই resolve করবে না, session-এর মাঝপথেই।
Cookie security flags — প্রতিটার নির্দিষ্ট কাজ
Set-Cookie হেডারে তিনটা flag আজকের বিষয়ে সবচেয়ে গুরুত্বপূর্ণ, প্রতিটা আলাদা আক্রমণ-শ্রেণী ঠেকায়:
| Flag | কী করে | কোন আক্রমণ ঠেকায় (আজ শুধু নাম, গভীরতা পরের লেসনে) |
|---|---|---|
HttpOnly | Cookie-টা JavaScript (document.cookie) থেকে পড়া যাবে না | XSS — কোনো malicious script inject হলেও সেটা session cookie চুরি করতে পারবে না |
Secure | Cookie শুধু HTTPS-connection-এ পাঠানো হবে, plain HTTP-তে না | Network sniffing — plaintext-এ session ID কখনো তারে ভাসবে না |
SameSite | Strict/Lax/None — cross-site request-এ cookie কবে পাঠানো হবে তা নিয়ন্ত্রণ করে | CSRF — অন্য কোনো সাইট থেকে চুপচাপ ছোঁড়া request-এ আপনার session cookie জুড়ে যাওয়া আটকায় |
Set-Cookie: session=8f3a...e21c; HttpOnly; Secure; SameSite=Lax; Max-Age=3600লক্ষ্য করুন — এই তিনটা flag-ই browser-কে দেওয়া নির্দেশ, cookie-র ভেতরের ডেটা এনক্রিপ্ট করে না, শুধু browser কখন-কার কাছে সেটা প্রকাশ করবে তা নিয়ন্ত্রণ করে। XSS আর CSRF-এর বিস্তারিত যান্ত্রিকতা এই লেসনের বাইরে — আজ শুধু এইটুকু ধরে রাখা যথেষ্ট: প্রতিটা flag একটা নির্দিষ্ট, নামযুক্ত hole বন্ধ করে, “সাধারণভাবে নিরাপদ করা” কোনো fuzzy গুণ না।
JWT — client নিজেই একটা স্বাক্ষরিত প্রমাণ বহন করে
Session-cookie মডেলের একটা কাঠামোগত নির্ভরতা আছে: প্রতিটা request-এ সার্ভারকে session store-এ lookup করতে হয়। একটা single server-এ এটা তুচ্ছ; কিন্তু ১০০টা server load-balanced থাকলে, প্রতিটা server-কে হয় একই shared store (Redis cluster) ব্যবহার করতে হবে, নয়তো session “sticky” রাখতে হবে একটা নির্দিষ্ট server-এ — দুইটাই অতিরিক্ত অপারেশনাল জটিলতা।
JWT সম্পূর্ণ উল্টো দর্শন নেয়: session state client-এই থাকুক, কিন্তু এমনভাবে যাতে client সেটা বদলাতে না পারে। সার্ভার state-কে একটা JSON object বানিয়ে নিজের secret key দিয়ে সাইন করে client-কে দিয়ে দেয়। পরের request-এ client সেই signed token-টাই ফেরত পাঠায় — সার্ভার শুধু signature verify করে, কোনো DB lookup ছাড়াই বুঝে যায় token-টা সত্যিই তারই ইস্যু করা এবং অপরিবর্তিত।
session-cookie: cookie = opaque-id → সার্ভার প্রতি-request DB/store lookup করে state বের করে
JWT: token = signed-state → সার্ভার প্রতি-request শুধু signature verify করে, lookup লাগে নাভেতরে কী ঘটছে
JWT-এর গঠন — header.payload.signature, তিনটা অংশ decode করে দেখা
একটা JWT দেখতে এরকম — তিনটা অংশ . দিয়ে আলাদা, প্রতিটাই base64url-encoded:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MDAwMDM2MDAsImlhdCI6MTcwMDAwMDAwMCwicm9sZSI6InVzZXIiLCJzdWIiOiJ1c2VyLTQ0NzEifQ.9jxFuIdrSOCqpE-MtcxAnRBJ9j3mWq4mwH8qyLveI1s
└──────────────── header ────────────────┘└──────────────────────── payload ────────────────────────┘└──────────── signature ────────────┘header বলে দেয় কোন algorithm সাইন করেছে (HS256 = HMAC-SHA256), payload-এ actual claims (sub, role, exp ইত্যাদি), signature হলো HMAC(secret, header_b64 + "." + payload_b64) — শুধু প্রথম দুই অংশের ওপর, secret দিয়ে গণনা করা একটা tag। নিচের কোডটা সত্যিই চালিয়ে, Python std-library দিয়ে (কোনো external JWT library ছাড়া) শুধু base64.urlsafe_b64decode ব্যবহার করে header আর payload বের করা হলো — লক্ষ্য করুন কোনো SECRET লাগেনি:
# hood_decode.py -- JWT-এর তিনটা অংশ base64url দিয়ে decode করা -- কোনো SECRET লাগে না
import json, base64
token = ("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
"eyJleHAiOjE3MDAwMDM2MDAsImlhdCI6MTcwMDAwMDAwMCwicm9sZSI6InVzZXIiLCJzdWIiOiJ1c2VyLTQ0NzEifQ."
"9jxFuIdrSOCqpE-MtcxAnRBJ9j3mWq4mwH8qyLveI1s")
def b64url_decode(s: str) -> bytes:
pad = "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s + pad)
header_b64, payload_b64, sig_b64 = token.split(".")
print("token-এর তিনটা অংশ (dot দিয়ে আলাদা):")
print(f" header = {header_b64}")
print(f" payload = {payload_b64}")
print(f" signature = {sig_b64}")
print()
header = json.loads(b64url_decode(header_b64))
payload = json.loads(b64url_decode(payload_b64))
print(f"base64url-decode(header) = {header}")
print(f"base64url-decode(payload) = {payload}")
print()
print(f"signature raw bytes (হেক্স) = {b64url_decode(sig_b64).hex()}")
print(f"signature-এর দৈর্ঘ্য = {len(b64url_decode(sig_b64))} byte "
f"(HMAC-SHA256 digest-এর ঠিক সমান)")প্রত্যাশিত output (deterministic — হুবহু এটাই আসবে):
token-এর তিনটা অংশ (dot দিয়ে আলাদা):
header = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
payload = eyJleHAiOjE3MDAwMDM2MDAsImlhdCI6MTcwMDAwMDAwMCwicm9sZSI6InVzZXIiLCJzdWIiOiJ1c2VyLTQ0NzEifQ
signature = 9jxFuIdrSOCqpE-MtcxAnRBJ9j3mWq4mwH8qyLveI1s
base64url-decode(header) = {'alg': 'HS256', 'typ': 'JWT'}
base64url-decode(payload) = {'exp': 1700003600, 'iat': 1700000000, 'role': 'user', 'sub': 'user-4471'}
signature raw bytes (হেক্স) = f63c45b8876b48e0aaa44f8cb5cc409d1049f63de65aae26c07f2ac8bbde235b
signature-এর দৈর্ঘ্য = 32 byte (HMAC-SHA256 digest-এর ঠিক সমান)সংখ্যাগুলো নিজেই misconception ভাঙে: role আর sub একদম প্লেইন dictionary হিসেবে বেরিয়ে এসেছে, কোনো secret ছাড়াই — base64url শুধু encoding, encryption না, ঠিক আগের লেসনের ভাষায় বললে base64 হলো একটা reversible representation change, cryptographic primitive না। যা attacker-কে আটকায় সেটা শেষ ৩২ byte-এর signature — সেটা পরিবর্তন করা যায় না secret জানা ছাড়া, এই দাবিটাই পরের অংশে সাইন-করে-tamper-করে প্রমাণ করা হবে।
উদাহরণ
Worked comparison — একই login, দুই মডেলে request/response
ধরুন user-4471 login করল। দুই মডেলে ঠিক কী কী network-এ যায়, তা পাশাপাশি রাখা যাক:
Session-cookie মডেল:
১. POST /login {username, password}
২. Server: password verify → session store-এ SET session:8f3a... = {uid:4471, role:"user"}
Response: Set-Cookie: session=8f3a...; HttpOnly; Secure; SameSite=Lax
৩. GET /orders
Cookie: session=8f3a...
Server: GET session:8f3a... থেকে store-এ lookup ← প্রতি-request store hit
→ {uid:4471, role:"user"} পাওয়া গেল → response পাঠানোJWT মডেল:
১. POST /login {username, password}
২. Server: password verify → token = sign({uid:4471, role:"user", exp:...}, SECRET)
Response: {"access_token": "eyJhbGc...9jxFu..."} (বা cookie হিসেবেও দেওয়া যায়)
৩. GET /orders
Authorization: Bearer eyJhbGc...9jxFu...
Server: verify(token, SECRET) ← শুধু গণনা, কোনো store-hit নেই
→ signature ঠিক থাকলে {uid:4471, role:"user"} সরাসরি payload থেকেই পাওয়া গেল
| দিক | Session cookie | JWT |
|---|---|---|
| State কোথায় থাকে | সার্ভার-সাইড store | Client-এর কাছে, signed |
| প্রতি-request খরচ | store lookup (network hop, ধীর) | signature verify (লোকাল CPU, দ্রুত) |
| Multi-server scaling | shared store লাগবে | কিছু লাগবে না — যেকোনো server verify করতে পারে |
| Revoke করা | store থেকে entry মুছে instant | কঠিন — token expire না হওয়া পর্যন্ত valid থাকবেই |
| Transport | সাধারণত cookie (browser-automatic) | সাধারণত Authorization: Bearer header (explicit) |
শেষ দুইটা row-ই আজকের লেসনের মূল টেনশন — পরের “diagram” আর “realworld” অংশে এই দুইটাই বিস্তারিত।
ছবিতে
Client Server Session Store
│──POST /login──────────►│ │
│ │──verify password──────────────►│ (হয় না; শুধু নিজে করে)
│ │──SET session:8f3a → {uid:4471}►│
│◄──Set-Cookie: session=8f3a───│ │
│ │ │
│──GET /orders───────────►│ │
│ Cookie: session=8f3a │──GET session:8f3a─────────────►│
│ │◄──{uid:4471, role:"user"}──────│
│◄──200 OK────────────────│ │
│ │ │
│ [admin চায়: DELETE session:8f3a] │
│──GET /orders───────────►│ │
│ Cookie: session=8f3a │──GET session:8f3a─────────────►│
│ │◄──(nil)─────────────────────────│
│◄──401 Unauthorized──────│ ← তাৎক্ষণিক revoke হয়ে গেলClient Server (কোনো session-store নেই)
│──POST /login──────────►│
│ │──verify password, token = sign({uid:4471,...,exp}, SECRET)
│◄──{access_token: "..."}│
│ │
│──GET /orders───────────►│
│ Authorization: Bearer │──verify(token, SECRET) ← শুধু CPU-গণনা, কোনো I/O নেই
│◄──200 OK────────────────│
│ │
│ [admin চায়: এই token-টা এখনই বাতিল করো]
│ │ ← কোনো সরাসরি উপায় নেই! token exp না হওয়া পর্যন্ত
│──GET /orders───────────►│ এখনও সিগনেচার-ভ্যালিড, সার্ভার মেনে নেবে
│ Authorization: Bearer │──verify(token, SECRET) → এখনও পাশ
│◄──200 OK (এখনও!)────────│দ্বিতীয় ছবিটাই stateless মডেলের আসল দাম — এটাই পরের “experiment”-এর পরে “realworld” অংশে refresh-token pattern দিয়ে সমাধান করা হবে।
নিজে চালিয়ে দেখুন
alg:none — signature-check বাদ পড়লে কী ঘটে, সংখ্যায়
এটাই ইতিহাসের সবচেয়ে বিখ্যাত JWT vulnerability class: alg:none / algorithm-confusion। JWT স্পেসিফিকেশনে alg নামের একটা header field আছে যা বলে দেয় কোন algorithm দিয়ে সাইন করা হয়েছে — এমনকি "none" নামের একটা বৈধ মান-ও আছে (কিছু ব্যবহারিক ক্ষেত্রে unsigned token পাঠানোর জন্য, যেমন দুইটা trusted party-র মধ্যে already-secured channel-এ)। সমস্যাটা তখনই শুরু হয় যখন কোনো library/verifier সিদ্ধান্ত নেয় কোন algorithm ব্যবহার করবে তা token নিজেই বলে দিক।
# alg_none.py -- classic "alg:none" / algorithm-confusion JWT vulnerability
import json, hmac, hashlib, base64
def b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def b64url_decode(s: str) -> bytes:
pad = "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s + pad)
SECRET = b"super-secret-server-key-32bytes!"
def sign_hs256(payload: dict) -> str:
header = {"alg": "HS256", "typ": "JWT"}
h = b64url_encode(json.dumps(header, separators=(",", ":"), sort_keys=True).encode())
p = b64url_encode(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode())
sig = hmac.new(SECRET, f"{h}.{p}".encode(), hashlib.sha256).digest()
return f"{h}.{p}.{b64url_encode(sig)}"
# ---------- দুর্বল verifier: token নিজের 'alg' header বিশ্বাস করে ----------
def verify_vulnerable(token: str) -> dict:
h_b64, p_b64, sig_b64 = token.split(".")
header = json.loads(b64url_decode(h_b64))
payload = json.loads(b64url_decode(p_b64))
alg = header.get("alg", "HS256")
if alg.lower() == "none":
# bug: signature check সম্পূর্ণ বাদ দেওয়া হয়েছে -- token নিজেই বলে দিচ্ছে
# "আমাকে বিশ্বাস করো, sign করার দরকার নেই"
return payload
expected = hmac.new(SECRET, f"{h_b64}.{p_b64}".encode(), hashlib.sha256).digest()
if not hmac.compare_digest(expected, b64url_decode(sig_b64)):
raise ValueError("signature invalid")
return payload
# ---------- নিরাপদ verifier: server নিজেই algorithm ঠিক করে, token-কে জিজ্ঞেস করে না ----------
def verify_safe(token: str, expected_alg="HS256") -> dict:
h_b64, p_b64, sig_b64 = token.split(".")
header = json.loads(b64url_decode(h_b64))
if header.get("alg") != expected_alg:
raise ValueError(f"rejected: alg={header.get('alg')!r}, server শুধু {expected_alg!r} মানে")
expected = hmac.new(SECRET, f"{h_b64}.{p_b64}".encode(), hashlib.sha256).digest()
if not hmac.compare_digest(expected, b64url_decode(sig_b64)):
raise ValueError("signature invalid")
return json.loads(b64url_decode(p_b64))
# ---------- আসল, বৈধ user token ----------
legit = sign_hs256({"sub": "user-4471", "role": "user"})
print(f"legit token verifies as: {verify_vulnerable(legit)}")
print()
# ---------- attacker: alg=none, কোনো secret না জেনেই forge ----------
forged_header = {"alg": "none", "typ": "JWT"}
forged_payload = {"sub": "user-4471", "role": "admin"}
h_b64 = b64url_encode(json.dumps(forged_header, separators=(",", ":"), sort_keys=True).encode())
p_b64 = b64url_encode(json.dumps(forged_payload, separators=(",", ":"), sort_keys=True).encode())
forged_token = f"{h_b64}.{p_b64}." # signature অংশ ফাঁকা -- 'none' মানেই sign করা হয়নি
print("forged token (alg=none, empty signature, কোনো SECRET জানা লাগেনি):")
print(f" {forged_token}")
print()
print("--- দুর্বল verifier (token-এর নিজের 'alg' মেনে চলে) ---")
try:
result = verify_vulnerable(forged_token)
print(f" ACCEPTED -> {result} *** attacker admin হয়ে গেল! ***")
except ValueError as e:
print(f" rejected: {e}")
print()
print("--- নিরাপদ verifier (server নিজেই expected_alg='HS256' fix করে রেখেছে) ---")
try:
result = verify_safe(forged_token)
print(f" ACCEPTED -> {result}")
except ValueError as e:
print(f" rejected: {e}")প্রত্যাশিত output (deterministic — হুবহু এটাই আসবে):
legit token verifies as: {'role': 'user', 'sub': 'user-4471'}
forged token (alg=none, empty signature, কোনো SECRET জানা লাগেনি):
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJyb2xlIjoiYWRtaW4iLCJzdWIiOiJ1c2VyLTQ0NzEifQ.
--- দুর্বল verifier (token-এর নিজের 'alg' মেনে চলে) ---
ACCEPTED -> {'role': 'admin', 'sub': 'user-4471'} *** attacker admin হয়ে গেল! ***
--- নিরাপদ verifier (server নিজেই expected_alg='HS256' fix করে রেখেছে) ---
rejected: rejected: alg='none', server শুধু 'HS256' মানেমূল মুহূর্ত ACCEPTED -> {'role': 'admin', ...} — attacker SECRET-এর একটা byte-ও না জেনে, শুধু header-এ "alg": "none" লিখে আর signature অংশ ফাঁকা রেখে, নিজের বেছে নেওয়া যেকোনো claim (এখানে role: admin) সরাসরি সার্ভারকে বিশ্বাস করিয়ে দিল। বাস্তব ইতিহাসে (২০১৫ সালের দিকে) একাধিক জনপ্রিয় JWT library ঠিক এই bug-এ ধরা পড়ে — alg header trust করে decode করত, caller algorithm নির্দিষ্ট না করলে। একই পরিবারের একটা আরও সূক্ষ্ম ভ্যারিয়েন্ট: server RSA (asymmetric, RS256) দিয়ে সাইন করছে ভেবে token verify করছে, কিন্তু attacker alg: HS256 পাঠিয়ে public key-টাকেই HMAC secret হিসেবে ব্যবহার করে — public key তো নামেই প্রকাশ্য, attacker-এর হাতে সহজেই থাকে। দুইটা bug-এরই মূল কারণ এক লাইনে: verifier যদি “কীভাবে verify করব” তা token-কে জিজ্ঞেস করে, attacker সেই প্রশ্নের উত্তর নিজেই লিখে দিতে পারে।
যদি verifier token-এর নিজের 'alg' header বিশ্বাস করে (server নিজে algorithm fix না করে), attacker কোনো SECRET না জেনেই alg='none' আর ফাঁকা signature দিয়ে role='admin' claim ঢুকিয়ে একটা 'বৈধ' token forge করতে পারে — অথচ server নিজে expected_alg fix করে রাখলে সেই একই forged token সঙ্গে সঙ্গে reject হয়।
নিজে বানান
একটা ছোট JWT লাইব্রেরি — sign, decode, verify, আর tamper-detection
- b64url_encode/decode লিখুন — padding-ছাড়া base64url, JWT-এর standard encoding
- jwt_encode লিখুন — header+payload-কে b64url করে, HMAC-SHA256 দিয়ে সাইন করে তিন-অংশের token বানান
- jwt_decode_unsafe লিখুন — শুধু decode, কোনো verify না, দেখান যে কেউ payload পড়তে পারে
- jwt_verify লিখুন — signature recompute করে hmac.compare_digest দিয়ে মেলান, না মিললে ValueError
- একটা issued token-এর payload-এ role বদলে দিন (signature অপরিবর্তিত রেখে) — verify যে fail করে তা দেখান
লক্ষ্য: প্রমাণ করা যে signature আসলেই কাজ করছে — শুধু দাবি না করে, একটা প্রকৃত tamper attempt চালিয়ে দেখানো যে সেটা ঠিকই ধরা পড়ে।
# jwt_toy.py -- toy JWT encoder/decoder/verifier (HMAC-SHA256), std-lib only
import json, hmac, hashlib, base64, time
def b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def b64url_decode(s: str) -> bytes:
pad = "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s + pad)
def jwt_encode(payload: dict, secret: bytes, alg="HS256") -> str:
header = {"alg": alg, "typ": "JWT"}
header_b64 = b64url_encode(json.dumps(header, separators=(",", ":"), sort_keys=True).encode())
payload_b64 = b64url_encode(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode())
signing_input = f"{header_b64}.{payload_b64}".encode()
sig = hmac.new(secret, signing_input, hashlib.sha256).digest()
sig_b64 = b64url_encode(sig)
return f"{header_b64}.{payload_b64}.{sig_b64}"
def jwt_decode_unsafe(token: str) -> dict:
# decodes WITHOUT verifying -- দেখায় যে কেউ payload পড়তে পারে
header_b64, payload_b64, _ = token.split(".")
header = json.loads(b64url_decode(header_b64))
payload = json.loads(b64url_decode(payload_b64))
return {"header": header, "payload": payload}
def jwt_verify(token: str, secret: bytes) -> dict:
header_b64, payload_b64, sig_b64 = token.split(".")
signing_input = f"{header_b64}.{payload_b64}".encode()
expected_sig = hmac.new(secret, signing_input, hashlib.sha256).digest()
given_sig = b64url_decode(sig_b64)
if not hmac.compare_digest(expected_sig, given_sig):
raise ValueError("signature verification FAILED -- token tampered or wrong secret")
payload = json.loads(b64url_decode(payload_b64))
return payload
SECRET = b"super-secret-server-key-32bytes!"
# ---------- ১. সাইন করা ----------
payload = {"sub": "user-4471", "role": "user", "iat": 1_700_000_000, "exp": 1_700_003_600}
token = jwt_encode(payload, SECRET)
print("issued token:")
print(f" {token}")
print()
parts = token.split(".")
print(f"header.payload.signature -> {len(parts)} parts, byte-lengths = "
f"{[len(p) for p in parts]}")
print(f" header (decoded) = {jwt_decode_unsafe(token)['header']}")
print(f" payload (decoded) = {jwt_decode_unsafe(token)['payload']}")
print()
# ---------- ২. যাচাই -- সঠিক secret ----------
verified = jwt_verify(token, SECRET)
print(f"verify with correct secret -> OK, payload = {verified}")
print()
# ---------- ৩. tamper: payload-এ role বদলে দেওয়া ----------
header_b64, payload_b64, sig_b64 = token.split(".")
forged_payload = {"sub": "user-4471", "role": "admin", "iat": 1_700_000_000, "exp": 1_700_003_600}
forged_payload_b64 = b64url_encode(json.dumps(forged_payload, separators=(",", ":"), sort_keys=True).encode())
forged_token = f"{header_b64}.{forged_payload_b64}.{sig_b64}" # পুরনো signature-ই রাখা হলো
print("tampered token (role: user -> admin, signature অপরিবর্তিত):")
print(f" {forged_token}")
try:
jwt_verify(forged_token, SECRET)
print(" verify -> OK (এটা হওয়ার কথা না!)")
except ValueError as e:
print(f" verify -> REJECTED: {e}")প্রত্যাশিত output (deterministic — হুবহু এটাই আসবে):
issued token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MDAwMDM2MDAsImlhdCI6MTcwMDAwMDAwMCwicm9sZSI6InVzZXIiLCJzdWIiOiJ1c2VyLTQ0NzEifQ.9jxFuIdrSOCqpE-MtcxAnRBJ9j3mWq4mwH8qyLveI1s
header.payload.signature -> 3 parts, byte-lengths = [36, 90, 43]
header (decoded) = {'alg': 'HS256', 'typ': 'JWT'}
payload (decoded) = {'exp': 1700003600, 'iat': 1700000000, 'role': 'user', 'sub': 'user-4471'}
verify with correct secret -> OK, payload = {'exp': 1700003600, 'iat': 1700000000, 'role': 'user', 'sub': 'user-4471'}
tampered token (role: user -> admin, signature অপরিবর্তিত):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MDAwMDM2MDAsImlhdCI6MTcwMDAwMDAwMCwicm9sZSI6ImFkbWluIiwic3ViIjoidXNlci00NDcxIn0.9jxFuIdrSOCqpE-MtcxAnRBJ9j3mWq4mwH8qyLveI1s
verify -> REJECTED: signature verification FAILED -- token tampered or wrong secretমূল মুহূর্ত শেষ লাইনে: role মাত্র একটা শব্দ (user → admin) বদলানোর ফলে base64url-encoded payload-ও বদলে যায়, কিন্তু attacker পুরোনো signature-টাই রেখে দিয়েছিল (কারণ নতুন সঠিক signature বানাতে SECRET লাগবে, যা attacker-এর কাছে নেই) — ফলাফল, jwt_verify recompute করা signature আর token-এ থাকা signature মেলেনি, hmac.compare_digest তা ধরে ফেলেছে। এটাই JWT-র পুরো নিরাপত্তা-ভিত্তি: payload-এর একটা byte বদলালেও, বৈধ signature বানানো সম্ভব শুধু SECRET জানা থাকলেই।
নিজে বাড়ান:
jwt_verify-এexpclaim check যোগ করুন — বর্তমান সময়exp-এর পরে হলেValueError("token expired")তুলুনhmac.compare_digest-এর বদলে সাধারণ==দিয়ে সিগনেচার তুলনা করলে কী ধরনের timing-attack সুযোগ তৈরি হয়, ব্যাখ্যা করুন (ইঙ্গিত: byte-by-byte early-exit comparison)jwt_encode-এalgপ্যারামিটার caller নির্ধারণ করতে দেওয়া হয়েছে — experiment অংশেরalg:nonebug-এর সাথে এই ডিজাইন-সিদ্ধান্তের সম্পর্ক কী? কীভাবেjwt_verify-কে redesign করবেন যাতে caller কখনো ভুলবশত dangerousalgaccept না করে?
বাস্তব সিস্টেমে
বাস্তব জগতে authentication
Stateless বনাম stateful — trade-off, decision নয় একবারের জন্য। Diagram অংশে দেখা revocation-সমস্যা সমাধানের বাস্তব প্যাটার্ন: short-lived access token + long-lived refresh token। Access token (JWT, সাধারণত ৫-১৫ মিনিট validity) প্রতি-request পাঠানো হয়, verify দ্রুত, কোনো store-hit লাগে না। Refresh token (সাধারণত দিন-সপ্তাহ validity) সার্ভার-সাইড track করা হয় — কম ব্যবহৃত হওয়ায় extra store-lookup-এর খরচ সহনীয়, আর দরকার হলে সেই refresh token invalidate করে দিলে নতুন access token ইস্যু বন্ধ হয়ে যায়। ফলে exposure window সর্বোচ্চ access-token-এর validity-তে সীমিত থাকে (কয়েক মিনিট), পুরো revocation সমস্যা সম্পূর্ণ সমাধান না হলেও ব্যবহারিকভাবে সহনীয় হয়ে যায়।
OAuth2 — delegated authorization, authentication নয়। এই নামটাই সবচেয়ে বেশি বিভ্রান্তি তৈরি করে। OAuth2 আসলে “who are you” প্রশ্নের উত্তর দেয় না — এটা দেয় “এই third-party app-কে তোমার হয়ে এই নির্দিষ্ট কাজ করার অনুমতি দিলাম” প্রশ্নের উত্তর। যখন কোনো app “Login with Google” বাটন দেখায়, ব্যবহারকারী আসলে Google-কে বলছে “এই app-কে আমার profile পড়ার অনুমতি দাও” — অনুমতি (authorization), পরিচয়-প্রমাণ (authentication) সরাসরি নয়। Authorization code flow এই delegated-authorization-এর আজকের সবচেয়ে নিরাপদ pattern, ধাপগুলো:
১. App user-কে Google-এর authorization page-এ পাঠায় (redirect), সাথে client_id, redirect_uri, requested scope
২. User Google-এ login করে (যদি না থাকে) আর "permission দাও" বাটনে ক্লিক করে
৩. Google browser-কে app-এর redirect_uri-তে ফেরত পাঠায়, সাথে একটা এক-বারের-জন্য authorization code (URL-এ দৃশ্যমান, কিন্তু short-lived)
৪. App-এর ব্যাকএন্ড (browser নয়!) সরাসরি Google-এর টোকেন-endpoint-এ সেই code + client_secret পাঠিয়ে
প্রকৃত access token exchange করে ← এই ধাপটাই ব্যাকএন্ড-টু-ব্যাকএন্ড, browser-এ কখনো token দেখা যায় না
৫. App এখন সেই access token দিয়ে Google API-তে (user-এর profile ইত্যাদি) request করতে পারেচতুর্থ ধাপটাই ডিজাইনের কেন্দ্র: প্রকৃত access token কখনো browser-এর মধ্য দিয়ে যায় না, শুধু একটা short-lived, single-use code যায় — সেই code চুরি হলেও, client_secret ছাড়া token exchange করা যায় না।
OIDC — OAuth2-এর ওপর পাতলা identity layer। যেহেতু OAuth2 নিজে authentication না, “who is this user” প্রশ্নের একটা standardized উত্তর দরকার হয়েছিল — OpenID Connect (OIDC) ঠিক সেটাই যোগ করে: access token-এর পাশাপাশি একটা ID Token (নিজেই একটা JWT, sub, email, name-জাতীয় identity claim বহন করে, iss-দ্বারা signed) ইস্যু হয়। “Sign in with Google/GitHub/Apple” বাটনগুলো আসলে OAuth2 + OIDC একসাথে ব্যবহার করে — delegated authorization-এর কাঠামোর ওপর একটা standardized identity-claim বসিয়ে।
Production incident-এর প্যাটার্ন। ২০১৫ সালের দিকে একাধিক জনপ্রিয় ভাষার JWT library-তে ঠিক আজকের experiment-এর alg:none bug পাওয়া গিয়েছিল — decode function caller-কে expected algorithm নির্দিষ্ট করতে বাধ্য করত না, token-এর নিজের alg header বিশ্বাস করত। ফলাফল ছিল library-level সংশোধন: আধুনিক JWT library-গুলো এখন decode(token, key, algorithms=["HS256"])-এর মতো explicit algorithm-allowlist ছাড়া কাজই করে না।
যে ভুলগুলো সবাই করে
“JWT এনক্রিপ্টেড, তাই payload-এ user-এর sensitive তথ্য নিরাপদে রাখা যায়।”
Hood অংশেই সংখ্যায় দেখা গেছে — কোনো SECRET ছাড়াই payload সম্পূর্ণ decode করা গেছে। JWT base64url-encoded, encrypted না — signature শুধু tamper-detection দেয়, confidentiality না। যে কেউ token কপি করলে পুরো payload প্লেইন-টেক্সটে পড়ে ফেলতে পারে। Encrypted payload দরকার হলে JWE (JSON Web Encryption) — সম্পূর্ণ আলাদা স্পেসিফিকেশন — লাগবে, শুধু signed JWT (JWS) দিয়ে নয়।
“OAuth2 হলো একটা authentication protocol — 'Login with Google' মানেই OAuth2 দিয়ে authenticate করা।”
OAuth2 নিজে শুধু delegated authorization — “এই app-কে এই সীমিত কাজের অনুমতি দিলাম”, identity প্রমাণ করে না। “Login with Google” বাটন আসলে OAuth2-এর ওপর OIDC যোগ করে identity-claim (ID Token) পায় — সেই অতিরিক্ত স্তরটাই authentication যোগ করে। খাঁটি OAuth2 (OIDC ছাড়া) দিয়ে “login” বানানো একটা পুরনো, ভঙ্গুর anti-pattern হিসেবে পরিচিত (access-token-কে identity-proof হিসেবে ভুল ব্যবহার করা)।
“HTTPS ব্যবহার করলে JWT-র signature verify করা optional — transport-level security-ই যথেষ্ট।”
HTTPS শুধু transit-এ ডেটা সুরক্ষিত রাখে (client-server-এর মাঝে কেউ পড়তে/বদলাতে পারবে না) — এটা কিছুই বলে না token-টা আসলেই সার্ভার ইস্যু করেছে কিনা, বা client-এর হাতে থাকা অবস্থায় কেউ (malicious extension, XSS, লোকাল storage-এ access পাওয়া কোনো প্রক্রিয়া) সেটা বদলায়নি কিনা। Signature verification আর transport encryption সম্পূর্ণ ভিন্ন axis-এর গ্যারান্টি — experiment অংশের alg:none bug-টা HTTPS-এর ওপরেও ঠিক একইভাবে কাজ করত, কারণ সমস্যাটা transport-এ না, verification-logic-এ।
“JWT সবসময় session cookie-র চেয়ে ভালো — 'stateless' মানেই 'আধুনিক আর নিরাপদ'।”
Example আর diagram অংশেই দেখা গেছে — stateless মানে instant revocation হারানো, প্রতিটা issued token তার exp-এর আগ পর্যন্ত বৈধ থেকেই যায়, কোনো “log out everywhere” বাটন সহজে কাজ করে না ব্লকলিস্ট ছাড়া (আর ব্লকলিস্ট রাখা মানেই আসলে আবার stateful হয়ে যাওয়া)। Session cookie-র ডাউনসাইড (shared store লাগে, প্রতি-request lookup) real, কিন্তু JWT-র ডাউনসাইডও সমান real — কোনটা কম ক্ষতিকর সেটা সিস্টেমের প্রয়োজনের ওপর নির্ভরশীল একটা trade-off, universal বিজয়ী নেই।
বুঝেছেন কি না দেখুন
1Session-cookie মডেলে সার্ভার client-কে ঠিক কী পাঠায়, আর সেটা কী পাঠায় না?
স্মরণ
সার্ভার client-কে শুধু একটা random, unguessable, opaque session ID পাঠায় — এটা কোনো actual session state (user-id, permissions ইত্যাদি) বহন করে না। সেই state সম্পূর্ণ সার্ভার-সাইড store-এ থাকে, session ID শুধু সেই store-এর একটা রেফারেন্স-key।
2HttpOnly, Secure, আর SameSite — তিনটা cookie flag-ই “নিরাপত্তা” দেয় বললে ভুল কিছু নেই, কিন্তু প্রতিটা ঠিক কোন আলাদা আক্রমণ-শ্রেণী টার্গেট করে তা মেলাতে বলা হলে কীভাবে আলাদা করবেন?
যুক্তি
HttpOnly, Secure, আর SameSite — তিনটা cookie flag-ই “নিরাপত্তা” দেয় বললে ভুল কিছু নেই, কিন্তু প্রতিটা ঠিক কোন আলাদা আক্রমণ-শ্রেণী টার্গেট করে তা মেলাতে বলা হলে কীভাবে আলাদা করবেন?HttpOnly — JavaScript থেকে cookie পড়া আটকায়, তাই XSS-এর মাধ্যমে session চুরি ঠেকায়। Secure — cookie শুধু HTTPS-এ পাঠানো হয়, তাই plaintext network-sniffing-এ session ID ফাঁস হওয়া ঠেকায়। SameSite — cross-site context-এ কবে cookie পাঠানো হবে তা নিয়ন্ত্রণ করে, তাই CSRF-এর মতো “অন্য সাইট থেকে চুপচাপ ছোঁড়া request”-এ session cookie জুড়ে যাওয়া ঠেকায়। তিনটাই ভিন্ন attack-vector, একটা থাকলেও বাকি দুইটা প্রয়োজন থেকেই যায়।
3Hood অংশে দেখানো হয়েছে JWT-র payload কোনো SECRET ছাড়াই decode করা যায়। তাহলে JWT-কে কেন “নিরাপদ” বলা হয় — ঠিক কোন গ্যারান্টিটা signature দেয়, আর কোনটা দেয় না?
প্রয়োগ
SECRET ছাড়াই decode করা যায়। তাহলে JWT-কে কেন “নিরাপদ” বলা হয় — ঠিক কোন গ্যারান্টিটা signature দেয়, আর কোনটা দেয় না?Signature integrity গ্যারান্টি দেয় — payload-এর একটা byte বদলালেই signature আর মিলবে না (build অংশের tamper-experiment এটাই প্রমাণ করেছে), তাই receiver নিশ্চিত থাকতে পারে token-টা প্রকৃতপক্ষে সার্ভার ইস্যু করেছিল এবং অপরিবর্তিত। Signature confidentiality গ্যারান্টি দেয় না — payload যে কেউ পড়তে পারে, কোনো secret ছাড়াই।
4Experiment অংশে দেখানো verify_vulnerable ফাংশনটা ঠিক কোন একটা সিদ্ধান্তের কারণে ভাঙে, আর verify_safe সেই একই সিদ্ধান্তটা কীভাবে ভিন্নভাবে নেয়?
যুক্তি
verify_vulnerable ফাংশনটা ঠিক কোন একটা সিদ্ধান্তের কারণে ভাঙে, আর verify_safe সেই একই সিদ্ধান্তটা কীভাবে ভিন্নভাবে নেয়?verify_vulnerable token-এর নিজের header["alg"] ফিল্ড দেখে সিদ্ধান্ত নেয় কীভাবে verify করবে — attacker সেই ফিল্ড নিজেই লিখে "none" বসিয়ে সম্পূর্ণ signature-check বাইপাস করে ফেলে। verify_safe সেই সিদ্ধান্তটা সার্ভার নিজে আগে থেকেই ঠিক করে রাখে (expected_alg="HS256" parameter) — token যা-ই দাবি করুক, header-এর alg সেই fixed মানের সাথে না মিললে সরাসরি reject। মূলনীতি: “কীভাবে verify করব” এই সিদ্ধান্ত কখনো untrusted input (token নিজে)-কে দেওয়া উচিত না।
5একটা ব্যাংকিং app-এ user “Log out from all devices” বাটনে চাপ দিল। যদি সিস্টেম pure JWT (কোনো blocklist ছাড়া) দিয়ে বানানো হয়, ঠিক কী ঘটবে — আর কেন সেটা সমস্যা?
প্রয়োগ
কিছুই তাৎক্ষণিকভাবে ঘটবে না — issued সব access token তাদের exp না হওয়া পর্যন্ত এখনও বৈধ থাকবে, কারণ pure JWT মডেলে সার্ভারের কাছে “কোন token-গুলো এখনও জীবিত” তার কোনো তালিকা নেই, প্রতিটা verify শুধু signature-গণনা। এটা সমস্যা কারণ user ধরে নিচ্ছে সব session সঙ্গে সঙ্গে বন্ধ হয়ে গেছে, অথচ কোনো device-এ চুরি যাওয়া token সেই exp পর্যন্ত পুরোপুরি কার্যকর থেকে যায়।
6একটা mobile banking app design করছেন — access token-এর exposure window কম রাখতে হবে (compromise হলেও ক্ষতি সীমিত), কিন্তু user-কে প্রতি ১৫ মিনিটে আবার password দিতে বলা যাবে না। কোন pattern বাছবেন, আর প্রতিটা token-এর validity আর storage-এর জন্য কী সিদ্ধান্ত নেবেন?
ডিজাইন
Short-lived JWT access token (৫-১৫ মিনিট validity, প্রতি API-call-এ পাঠানো, শুধু signature verify, দ্রুত) + long-lived refresh token (দিন-সপ্তাহ validity, সার্ভার-সাইড track করা এবং individually revocable, শুধু নতুন access token চাওয়ার সময় ব্যবহৃত হয় বলে কম-frequency store-lookup সহনীয়)। Refresh token কখনো JavaScript-accessible storage-এ না রেখে secure, HttpOnly storage-এ (বা mobile-এ platform keystore) রাখা উচিত; কোনো device compromise সন্দেহ হলে সেই নির্দিষ্ট refresh token সার্ভার-সাইড revoke করলেই নতুন access token ইস্যু বন্ধ হয়ে যাবে, exposure সর্বোচ্চ বিদ্যমান access token-এর অবশিষ্ট validity-তে সীমিত থাকবে।
এরপর কী
এরপর কী
আজ authentication-এর দুইটা মৌলিক দর্শন — stateful session cookie আর stateless JWT — হাতে-কলমে বানিয়ে, ভেঙে, আবার মেরামত করে দেখা হলো। সাথে OAuth2/OIDC কীভাবে এই একই primitive-গুলোর ওপর দাঁড়িয়ে delegated authorization আর identity সমাধান করে তাও দেখা হলো। কিন্তু একটা প্রশ্ন সচেতনভাবেই আজ ছোঁয়া হয়নি: authentication শুধু বলে “তুমি কে” — “তুমি কী করতে পারবে” সে বিষয়ে কিছুই বলে না।
একটা সঠিকভাবে signed, সঠিকভাবে verify হওয়া JWT বা একটা বৈধ session — দুইটাই শুধু নিশ্চিত করে identity যাচাই হয়েছে। তার মানে এই না যে সেই user নির্দিষ্ট কোনো resource পড়তে, বদলাতে, বা মুছতে পারবে। পরের লেসন — authorization — ঠিক এই সম্পূর্ণ আলাদা প্রশ্নের মুখোমুখি হয়, আর দেখাবে এটাই সবচেয়ে বেশি ভুলভাবে বানানো security-primitive: RBAC, ABAC, আর সেই ক্লাসিক bug যেখানে if (user.isLoggedIn()) চেক থাকে কিন্তু “এই user কি এই নির্দিষ্ট record-এর মালিক” সেই চেক থাকে না।
আরও পড়ুন
- The OAuth 2.0 Authorization Framework — RFC 6749 — D. Hardt (ed.) — IETF, 2012 · OAuth2-এর আনুষ্ঠানিক সংজ্ঞা — authorization code, implicit, client credentials সব grant type এখানেই
- JSON Web Token (JWT) — RFC 7519 — M. Jones, J. Bradley, N. Sakimura — IETF, 2015 · JWT-এর claims, structure, আর processing rules-এর আনুষ্ঠানিক সংজ্ঞা
- HTTP State Management Mechanism (Cookies) — RFC 6265 — A. Barth — IETF, 2011 · Cookie-র attribute (HttpOnly, Secure, SameSite পরবর্তী সংযোজন) আর browser-behavior-এর আনুষ্ঠানিক ভিত্তি
- OpenID Connect Core 1.0 — N. Sakimura, J. Bradley, M. Jones, B. de Medeiros, C. Mortimore — OpenID Foundation, 2014 · OAuth2-এর ওপর identity layer — ID Token, UserInfo endpoint, authentication vs authorization-এর স্পষ্ট বিভাজন