JWT और Session सिक्योरिटी की भूलें, और उनसे कैसे बचें
account takeover तक ले जाने वाली JWT और session ग़लतियाँ: algorithm confusion, कमज़ोर secrets, claim चेक की कमी, token स्टोरेज, rotation और असली logout।
· 7 मिनट का लेख · Lina Source LLC
JSON Web Tokens एक वाजिब फ़ॉर्मैट हैं जिनके धारदार किनारों की सूची लंबी है। टोकन ख़ुद शायद ही कभी समस्या होता है। बग इसमें रहते हैं कि उसकी पुष्टि कैसे होती है, वह कहाँ स्टोर होता है, कितनी देर जीता है और यूज़र के logout करने पर क्या होता है। इनमें से हर ग़लती का नतीजा एक ही है: किसी के पास ऐसा टोकन है जो नहीं होना चाहिए, और आपका सर्वर उसे स्वीकार कर लेता है।
सबसे ज़्यादा सामने आने वाली दो कमज़ोरियाँ हैं CWE-347, किसी क्रिप्टोग्राफ़िक signature की अनुचित पुष्टि, और CWE-613, अपर्याप्त session expiration। नीचे के उदाहरण jose इस्तेमाल करते हैं, JWTs के लिए व्यापक रूप से इस्तेमाल होने वाली एक JavaScript लाइब्रेरी जो Node.js, edge runtimes और ब्राउज़रों में चलती है।
Decode करना पुष्टि करना नहीं है
CWE-347 का सबसे सीधा रूप है टोकन की signature जाँचे बिना उसके claims पढ़ लेना। हर JWT लाइब्रेरी में डीबगिंग के लिए एक decode फ़ंक्शन होता है, और वह auth middleware में उससे ज़्यादा बार दिखता है जितना दिखना चाहिए। Decode किया गया टोकन बस base64 है जिसे कोई भी लिख सकता है। JavaScript कोडबेस में यह पैटर्न अक्सर किसी छोटे helper में छिपा होता है जो टोकन को dots पर तोड़कर बीच वाले हिस्से पर JSON.parse चला देता है। यह हर टेस्ट में काम करता है, क्योंकि टेस्ट टोकन वैध होते हैं, और प्रोडक्शन में यह हर गढ़ा हुआ टोकन स्वीकार कर लेता है।
import { decodeJwt, jwtVerify } from 'jose';
// Vulnerable: कोई भी sub में किसी भी यूज़र ID वाला टोकन बना सकता है
const claims = decodeJwt(token);
const userId = claims.sub;
// सही: पहले signature, algorithm और claims जाँचे जाते हैं
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
const verifiedUserId = payload.sub;alg none और algorithm confusion
JWT header बताता है कि टोकन किस algorithm से sign हुआ, और header हमलावर के नियंत्रण में है। उस पर भरोसा करने से दो क्लासिक हमले निकलते हैं। पहला है alg को none पर सेट करना: एक बिना sign किया टोकन जिसे कुछ पुरानी लाइब्रेरीज़ वैध मान लेती थीं। दूसरा है algorithm confusion: जो सर्वर RS256 की उम्मीद करता है पर header को algorithm चुनने देता है, उसे एक HS256 टोकन थमाया जा सकता है जो सर्वर की public key को HMAC secret की तरह इस्तेमाल करके sign किया गया हो। Public key सार्वजनिक है, इसलिए हमलावर कुछ भी sign कर सकता है।
आधुनिक लाइब्रेरीज़ दोनों से बचाव करती हैं, और jose jwtVerify में unsecured टोकन अस्वीकार करती है तथा जाँचती है कि key का प्रकार algorithm से मेल खाता है। सिर्फ़ डिफ़ॉल्ट पर भरोसा न करें। हर verify कॉल पर algorithm की सूची स्पष्ट रूप से pin करें, ताकि कोई भविष्य का रीफ़ैक्टर या लाइब्रेरी बदलाव उसे चुपचाप चौड़ा न कर दे। अगर आप एक से ज़्यादा algorithm स्वीकार करते हैं, जैसे किसी key migration के दौरान, तो दोनों स्पष्ट रूप से गिनाएँ और हर एक के लिए अलग key इस्तेमाल करें। Keys rotate करते समय header में एक kid रखें और अपनी ही सूची से kid के आधार पर key चुनें, कभी टोकन header में बताए किसी URL से नहीं।
कमज़ोर signing secrets
HS256 टोकन एक साझा secret से sign होता है। अगर secret छोटा या अंदाज़ लगाने योग्य है, जैसे secret, ऐप का नाम या किसी ट्यूटोरियल से कॉपी की गई value, तो जिस हमलावर के पास एक भी वैध टोकन है वह उसे आम टूल्स से offline brute-force करके किसी भी यूज़र के लिए टोकन sign कर सकता है। Offline अनुमान लगाने पर कोई rate limit नहीं होती।
- HS256 के लिए कम से कम 32 रैंडम bytes इस्तेमाल करें, जैसे openssl rand -base64 32 से बनाए गए।
- Secret को environment या किसी secret manager से लोड करें, और अगर वह ग़ायब या छोटा हो तो स्टार्टअप पर ही फ़ेल हो जाएँ।
- उसे कभी कमिट न करें, और अगर वह कभी कमिट हुआ हो तो rotate कर दें।
- अगर कई सर्विसों को टोकन verify करने हैं पर सिर्फ़ एक को जारी करने हैं, तो RS256 या EdDSA जैसा asymmetric algorithm इस्तेमाल करें ताकि verifiers के पास सिर्फ़ public key रहे।
exp, aud और iss validate करें
वैध signature सिर्फ़ यह साबित करती है कि टोकन किसने जारी किया। Claims तय करते हैं कि वह आपके लिए है या नहीं और अब भी चालू है या नहीं। बिना expiry वाला टोकन हमेशा के लिए वैध है। आपके मोबाइल API के लिए जारी टोकन आपकी admin सर्विस को सिर्फ़ इसलिए स्वीकार नहीं करना चाहिए कि दोनों एक ही identity provider पर भरोसा करते हैं। aud और iss इसी के लिए हैं।
import { SignJWT, jwtVerify } from 'jose';
const rawSecret = process.env.JWT_SECRET;
if (!rawSecret || rawSecret.length < 32) {
throw new Error('JWT_SECRET must be set and at least 32 characters');
}
const secret = new TextEncoder().encode(rawSecret);
const ISSUER = 'https://api.example.com';
const AUDIENCE = 'https://app.example.com';
export function signAccessToken(userId: string, tokenVersion: number) {
return new SignJWT({ tv: tokenVersion })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(userId)
.setIssuer(ISSUER)
.setAudience(AUDIENCE)
.setIssuedAt()
.setExpirationTime('10m')
.sign(secret);
}
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
issuer: ISSUER,
audience: AUDIENCE,
requiredClaims: ['exp', 'sub'],
});
return payload;
}jose जब भी exp मौजूद हो उसे जाँचता है, पर बिना exp वाला टोकन वरना पास हो जाता। requiredClaims विकल्प उस कमी को बंद करता है। किसी बाहरी identity provider के टोकन के लिए, उसके प्रकाशित key set से createRemoteJWKSet के ज़रिए verify करें और फिर भी algorithms, issuer तथा audience pin करें।
localStorage या httpOnly cookies
टोकन को localStorage में रखना उसे पेज की हर script के लिए पढ़ने योग्य बना देता है। एक XSS बग, या एक समझौता किया हुआ third-party script, और टोकन हमलावर तक भेजा जाकर expire होने तक कहीं से भी इस्तेमाल हो सकता है।
httpOnly cookie को JavaScript पढ़ नहीं सकता। XSS फिर भी गंभीर है, क्योंकि injected script पेज खुला रहने तक यूज़र के रूप में requests कर सकता है, पर वह लंबी उम्र वाला credential चुराकर बाद में replay नहीं कर सकता। अपने ही बैकएंड से बात करने वाले ब्राउज़र ऐप्स के लिए cookies बेहतर डिफ़ॉल्ट हैं। किसी अलग API को कॉल करने वाले single-page ऐप के लिए access token सिर्फ़ मेमोरी में और refresh token httpOnly cookie में रखना एक वाजिब बीच का रास्ता है।
// Express: सुरक्षित attributes वाली session cookie
res.cookie('__Host-session', accessToken, {
httpOnly: true, // JavaScript से पढ़ी नहीं जा सकती
secure: true, // सिर्फ़ HTTPS; __Host- prefix के लिए ज़रूरी
sameSite: 'lax', // cross-site POSTs पर नहीं भेजी जाती
path: '/', // __Host- prefix के लिए ज़रूरी
maxAge: 10 * 60 * 1000, // मिलीसेकंड, टोकन की उम्र से मेल खाता है
});__Host- prefix ब्राउज़र से कहता है कि cookie तब तक अस्वीकार कर दे जब तक वह Secure न हो, उसका path / न हो, और उसमें Domain attribute न हो, जिससे कोई समझौता किया हुआ subdomain उसे overwrite नहीं कर पाता।
SameSite और वह जो यह कवर नहीं करता
SameSite=Lax cross-site POST requests और subresource loads पर cookie रोक देता है, जिससे ज़्यादातर क्लासिक CSRF ख़त्म हो जाता है। वह फिर भी top-level GET navigations पर cookie भेजता है, इसलिए state बदलने वाला कोई भी GET endpoint खुला रहता है। Strict उन्हें भी रोकता है, पर साथ ही यूज़र्स को तब logout कर देता है जब वे ईमेल या किसी दूसरी साइट से आपके ऐप का लिंक खोलते हैं। None सुरक्षा बंद कर देता है और Secure माँगता है।
Lax के साथ यह नियम कि GET requests कभी state नहीं बदलतीं, एक मज़बूत आधार है। संवेदनशील mutations के लिए Origin header की जाँच या कोई CSRF token जोड़ें। याद रखें कि SameSite आपके registrable डोमेन के सभी subdomains को same-site मानता है, इसलिए कोई कमज़ोर subdomain अब भी requests गढ़ सकता है।
Rotation, revocation और असली logout
Stateless JWT को expire होने से पहले रद्द नहीं किया जा सकता; हर request पर डेटाबेस न जाँचने का यही सौदा है। CWE-613 बताता है कि जब यह सौदा नज़रअंदाज़ किया जाता है तो क्या होता है: logout, पासवर्ड बदलाव और अकाउंट निलंबन जो असल में एक्सेस ख़त्म नहीं करते। अकाउंट मिटाना, role बदलना और किसी को टीम से हटाना भी revocation घटनाएँ हैं, और हर एक अगली request पर असर करनी चाहिए, न कि अगले token expiry पर।
- Access tokens को कम उम्र का रखें, मिनटों की सीमा में, दिनों की नहीं।
- Refresh tokens को सर्वर-साइड, hash करके स्टोर करें, और हर इस्तेमाल पर rotate करें।
- अगर पहले से rotate हो चुका refresh token दोबारा इस्तेमाल हो, तो उसे चोरी मानें और पूरे token family को रद्द कर दें।
- यूज़र row और टोकन दोनों में एक token version जोड़ें; logout-everywhere, पासवर्ड बदलाव या निलंबन पर उसे बढ़ाएँ।
- Session fixation रोकने के लिए login पर session identifier फिर से बनाएँ।
export async function requireUser(token: string) {
const payload = await verifyAccessToken(token);
const user = await db.user.findUnique({
where: { id: payload.sub },
select: { id: true, tokenVersion: true, disabled: true },
});
// बढ़ा हुआ version या निष्क्रिय अकाउंट एक्सेस तुरंत ख़त्म कर देता है
if (!user || user.disabled || user.tokenVersion !== payload.tv) {
throw new Error('Session revoked');
}
return user;
}वह lookup प्रति request एक डेटाबेस read वापस ले आता है, जो revocation की ईमानदार क़ीमत है। कई ऐप्स cookie में एक opaque session ID और एक sessions टेबल के साथ ज़्यादा सरल और सुरक्षित रहते हैं। तब logout का मतलब है एक row मिटाना। JWTs वहाँ इस्तेमाल करें जहाँ उनकी ख़ूबियाँ सचमुच मदद करती हैं, जैसे सर्विसों के बीच कम उम्र के टोकन, न कि इसलिए कि वे किसी ट्यूटोरियल में डिफ़ॉल्ट हैं।
आप जो भी चुनें, logout सर्वर पर होना चाहिए। ब्राउज़र में cookie साफ़ करना या टोकन मिटाना सिर्फ़ यूज़र की कॉपी हटाता है; चुराई हुई कॉपी तब तक चलती रहती है जब तक सर्वर उसे मना न कर दे। Logout पर सर्वर-साइड session या refresh token मिटाएँ, cookie को उन्हीं नाम, path और attributes से साफ़ करें जिनसे वह सेट हुई थी, और अगर यूज़र ने हर जगह से साइन आउट चुना है तो token version बढ़ाएँ। पासवर्ड रीसेट को भी बाक़ी हर session ख़त्म करना चाहिए।
एक छोटी रिव्यू चेकलिस्ट
- Authentication रास्तों में decode कॉल खोजें।
- पुष्टि करें कि हर verify कॉल algorithms, issuer और audience pin करती है, और exp ज़रूरी बनाती है।
- जाँचें कि signing secret कैसे बनता है, लोड होता है और स्टार्टअप पर validate होता है।
- पता करें कि टोकन ब्राउज़र में कहाँ स्टोर होते हैं।
- टेस्ट करें कि logout, पासवर्ड बदलाव और अकाउंट निलंबन मौजूदा sessions ख़त्म करते हैं।
ये जाँचें ज़्यादातर कोड रास्तों को शुरू से आख़िर तक पढ़ने की बात हैं, और CodeAuditAgent भी ऑडिट को इसी तरह देखता है: फ़ाइंडिंग्स कोट की गई लाइन, CWE, एक exploit परिदृश्य और एक पैच के साथ आती हैं, ताकि छूटी हुई audience जाँच मिनटों में पुष्ट और ठीक की जा सके।