Foundationপ্রথম নীতি থেকে
LEVEL 10লেসন ৯/২০কঠিন৫০ মিনিট

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 সমাধান করে।

এই লেসন শেষে আপনি পারবেন

  • Session-cookie মডেল নিজের ভাষায় ব্যাখ্যা করতে পারবেন — কেন session state সার্ভার-সাইডে থাকে, cookie কেন শুধু একটা opaque ID বহন করে, আর HttpOnly/Secure/SameSite প্রতিটা flag ঠিক কোন hole বন্ধ করে তা নির্দিষ্ট করে বলতে পারবেন
  • JWT-এর header.payload.signature গঠন হাতে-কলমে বানিয়ে, base64url-encoding আর HMAC-signing-এর পার্থক্য প্রমাণ করতে পারবেন — encoding গোপন রাখে না, signing শুধু tamper ধরে
  • alg:none / algorithm-confusion vulnerability-এর যান্ত্রিকতা কোড চালিয়ে দেখাতে পারবেন, আর কেন server-নির্ধারিত algorithm (token-নির্ধারিত নয়) একমাত্র নিরাপদ নকশা তা যুক্তি দিতে পারবেন
  • Stateless (JWT) বনাম stateful (session) মডেলের trade-off — revocation, DB-lookup খরচ, horizontal scaling — একটা নির্দিষ্ট production scenario-তে বেছে নিয়ে যুক্তি দিতে পারবেন
  • OAuth2-এর authorization code flow আর OIDC-এর identity layer আলাদা করে ব্যাখ্যা করতে পারবেন — কেন OAuth2 নিজে 'authentication' নয়, আর implicit flow আজ কেন discouraged

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

আগের লেসনে 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 মডেল নিরস্ত্রভাবে সহজ, চারটা ধাপে:

১. 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-এর মাঝপথেই।

Set-Cookie হেডারে তিনটা flag আজকের বিষয়ে সবচেয়ে গুরুত্বপূর্ণ, প্রতিটা আলাদা আক্রমণ-শ্রেণী ঠেকায়:

Flagকী করেকোন আক্রমণ ঠেকায় (আজ শুধু নাম, গভীরতা পরের লেসনে)
HttpOnlyCookie-টা JavaScript (document.cookie) থেকে পড়া যাবে নাXSS — কোনো malicious script inject হলেও সেটা session cookie চুরি করতে পারবে না
SecureCookie শুধু HTTPS-connection-এ পাঠানো হবে, plain HTTP-তে নাNetwork sniffing — plaintext-এ session ID কখনো তারে ভাসবে না
SameSiteStrict/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 cookieJWT
State কোথায় থাকেসার্ভার-সাইড storeClient-এর কাছে, signed
প্রতি-request খরচstore lookup (network hop, ধীর)signature verify (লোকাল CPU, দ্রুত)
Multi-server scalingshared 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 হয়ে গেল
Session-cookie মডেল — প্রতিটা request সার্ভারকে session-store-এ ফিরে তাকাতে বাধ্য করে; store-এ entry মুছে দিলেই session তাৎক্ষণিক মৃত।
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 (এখনও!)────────│
JWT মডেল — সার্ভার কোনো store দেখে না, শুধু signature গণনা করে; revoke করার কোনো instant hook নেই, exp না হওয়া পর্যন্ত token বৈধ থেকেই যায়।

দ্বিতীয় ছবিটাই stateless মডেলের আসল দাম — এটাই পরের “experiment”-এর পরে “realworld” অংশে refresh-token pattern দিয়ে সমাধান করা হবে।

নিজে চালিয়ে দেখুন

EXPERIMENT

alg:none — signature-check বাদ পড়লে কী ঘটে, সংখ্যায়

macOS / Linux / Windows (Python 3)· ১৫ মিনিট

এটাই ইতিহাসের সবচেয়ে বিখ্যাত 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 হয়।

নিজে বানান

BUILD IT

একটা ছোট JWT লাইব্রেরি — sign, decode, verify, আর tamper-detection

Python · ●●●○○
  1. b64url_encode/decode লিখুন — padding-ছাড়া base64url, JWT-এর standard encoding
  2. jwt_encode লিখুন — header+payload-কে b64url করে, HMAC-SHA256 দিয়ে সাইন করে তিন-অংশের token বানান
  3. jwt_decode_unsafe লিখুন — শুধু decode, কোনো verify না, দেখান যে কেউ payload পড়তে পারে
  4. jwt_verify লিখুন — signature recompute করে hmac.compare_digest দিয়ে মেলান, না মিললে ValueError
  5. একটা 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 জানা থাকলেই।

নিজে বাড়ান:

  1. jwt_verify-এ exp claim check যোগ করুন — বর্তমান সময় exp-এর পরে হলে ValueError("token expired") তুলুন
  2. hmac.compare_digest-এর বদলে সাধারণ == দিয়ে সিগনেচার তুলনা করলে কী ধরনের timing-attack সুযোগ তৈরি হয়, ব্যাখ্যা করুন (ইঙ্গিত: byte-by-byte early-exit comparison)
  3. jwt_encode-এ alg প্যারামিটার caller নির্ধারণ করতে দেওয়া হয়েছে — experiment অংশের alg:none bug-এর সাথে এই ডিজাইন-সিদ্ধান্তের সম্পর্ক কী? কীভাবে jwt_verify-কে redesign করবেন যাতে caller কখনো ভুলবশত dangerous alg accept না করে?

বাস্তব সিস্টেমে

বাস্তব জগতে 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 বিজয়ী নেই।

বুঝেছেন কি না দেখুন

1

Session-cookie মডেলে সার্ভার client-কে ঠিক কী পাঠায়, আর সেটা কী পাঠায় না?

স্মরণ

সার্ভার client-কে শুধু একটা random, unguessable, opaque session ID পাঠায় — এটা কোনো actual session state (user-id, permissions ইত্যাদি) বহন করে না। সেই state সম্পূর্ণ সার্ভার-সাইড store-এ থাকে, session ID শুধু সেই store-এর একটা রেফারেন্স-key।

2

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, একটা থাকলেও বাকি দুইটা প্রয়োজন থেকেই যায়।

3

Hood অংশে দেখানো হয়েছে JWT-র payload কোনো SECRET ছাড়াই decode করা যায়। তাহলে JWT-কে কেন “নিরাপদ” বলা হয় — ঠিক কোন গ্যারান্টিটা signature দেয়, আর কোনটা দেয় না?

প্রয়োগ

Signature integrity গ্যারান্টি দেয় — payload-এর একটা byte বদলালেই signature আর মিলবে না (build অংশের tamper-experiment এটাই প্রমাণ করেছে), তাই receiver নিশ্চিত থাকতে পারে token-টা প্রকৃতপক্ষে সার্ভার ইস্যু করেছিল এবং অপরিবর্তিত। Signature confidentiality গ্যারান্টি দেয় না — payload যে কেউ পড়তে পারে, কোনো secret ছাড়াই।

4

Experiment অংশে দেখানো 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-এর স্পষ্ট বিভাজন