- 1. เป้าหมายของ Workshop
- 2. JWT ทำงานอย่างไร
- 3. เตรียมเครื่องมือและโครงการ
- 4. ออกแบบ Endpoint
- 5. เขียน server.mjs
- 6. ทดสอบด้วย curl
- 7. ทดสอบด้วย Postman
- 8. ตารางกรณีทดสอบที่ควรครอบคลุม
- 9. Authentication, Authorization และ Ownership
- 10. การนำไปต่อยอดเป็นระบบใช้งานจริง
- 11. แบบฝึกหัดสำหรับผู้เรียน
- แหล่งอ้างอิง
Workshop ภาษาไทยสำหรับการเรียนการสอนและ Web Blog — ตรวจสอบตัวอย่างวันที่ 13 ตุลาคม 2026
Express ช่วยจัดการ HTTP request, routing และ middleware บน Node.js ส่วน JWT (JSON Web Token) ใช้ส่ง claims เช่นรหัสผู้ใช้และเวลาหมดอายุระหว่างระบบ ใน Workshop นี้จะสร้าง Notes API ที่ให้ผู้ใช้สมัครสมาชิก เข้าสู่ระบบ และอ่านบันทึกของตนเองผ่าน access token
#1. เป้าหมายของ Workshop
เมื่อทำเสร็จ ผู้เรียนจะสามารถ:
- สร้าง Express API และรับส่ง JSON
- เก็บ password hash แทนรหัสผ่านจริง
- ออก access token และตรวจสอบ token ก่อนเข้าถึง API
- แยกการยืนยันตัวตน (Authentication) ออกจากการตรวจสอบสิทธิ์ (Authorization)
- ตรวจสอบเจ้าของข้อมูล เพื่อป้องกันการเปลี่ยน ID แล้วอ่านข้อมูลของผู้อื่น
- ทดสอบทั้งกรณีสำเร็จและกรณีถูกปฏิเสธด้วย curl หรือ Postman
ขอบเขต: ตัวอย่างเก็บผู้ใช้และบันทึกใน Map เพื่อเน้นกลไก JWT เมื่อหยุดหรือเริ่ม server ใหม่ ข้อมูลทั้งหมดจะหาย Token เดิมจะเข้าถึง API ไม่ได้เพราะไม่มีบัญชีที่ตรงกับ sub ในหน่วยความจำ ตัวอย่างยังไม่มีฐานข้อมูล, refresh token, email verification หรือระบบกู้คืนรหัสผ่าน
#2. JWT ทำงานอย่างไร
JWT ที่ลงลายเซ็นในตัวอย่างมีรูปแบบ:
base64url(header).base64url(payload).base64url(signature)
| ส่วน | หน้าที่ |
|---|---|
| Header | ระบุชนิด token และ algorithm เช่น HS256 |
| Payload | บรรจุ claims ที่ระบบใช้ เช่น sub, iss, aud, exp |
| Signature | ใช้ตรวจสอบความถูกต้องและความครบถ้วนของ header และ payload |
JWT ที่ลงลายเซ็นไม่ได้เข้ารหัส payload ผู้ที่มี token สามารถอ่าน claims ได้ จึงไม่ควรใส่รหัสผ่าน, secret หรือข้อมูลส่วนบุคคลที่ไม่จำเป็นลงใน payload และต้องใช้ HTTPS เมื่อนำระบบไปใช้งานจริง
ตัวอย่าง claims เพื่ออธิบายโครงสร้าง โดยตัวเลขเวลาเป็นเพียงตัวอย่าง:
{
"sub": "user-uuid",
"iss": "express-jwt-workshop",
"aud": "notes-api",
"iat": 1800000000,
"exp": 1800000900,
"token_use": "access"
}
| Claim | ความหมาย |
|---|---|
sub |
ID ของผู้ใช้ |
iss |
ระบบที่ออก token |
aud |
API ที่ตั้งใจให้รับ token |
iat |
เวลาที่ออก token หน่วยวินาทีนับจาก Unix epoch |
exp |
เวลาหมดอายุ หน่วยวินาทีนับจาก Unix epoch |
token_use |
claim ที่แอปกำหนดเองเพื่อระบุว่าเป็น access token |
อ่านเพิ่มเติม: RFC 7519
#ขั้นตอนการใช้งาน
- Client ส่ง email และ password ไปที่
/auth/login - Server ตรวจ password hash แล้วออก access token อายุ 15 นาที
- Client ส่ง
Authorization: Bearer <token>เมื่อเรียก API - Middleware ตรวจลายเซ็น, algorithm, issuer, audience และวันหมดอายุ
- Server ค้นหาผู้ใช้จาก
subแล้วตรวจสิทธิ์หรือเจ้าของข้อมูลก่อนตอบกลับ
ระบบนี้ตรวจ JWT ร่วมกับข้อมูลผู้ใช้ฝั่ง server จึงมีการอ่านสถานะผู้ใช้ทุก request การตรวจลายเซ็นได้โดยไม่เก็บ session ไม่ได้หมายความว่าแอปทั้งหมดจะไม่มี state
#3. เตรียมเครื่องมือและโครงการ
ใช้ Node.js 24.x สำหรับ Workshop นี้ พร้อม npm และ curl หรือ Postman ตัวอย่างได้รับการทดสอบด้วย Node.js 24.19.0
Express 5 รองรับการส่งข้อผิดพลาดจาก async route ไปยัง error middleware เมื่อ Promise ถูก reject อ่านรายละเอียดใน Express 5 migration guide
ตรวจสอบเครื่องมือ:
node --version
npm --version
สร้างโครงการและติดตั้ง dependency เวอร์ชันที่ใช้ตรวจสอบตัวอย่าง:
mkdir express-jwt-workshop
cd express-jwt-workshop
npm init -y
npm install express@5.3.0 jsonwebtoken@9.0.3 helmet@8.3.0 express-rate-limit@8.7.1
npm pkg set scripts.start="node --env-file=.env server.mjs"
npm pkg set scripts.dev="node --env-file=.env --watch server.mjs"
ไฟล์ .mjs ใช้ ES modules ได้โดยไม่ต้องเปลี่ยน type ของ package.json เก็บ package-lock.json ไว้ใน Git เพื่อให้สมาชิกทีมติดตั้งซ้ำด้วย npm ci ได้
โครงสร้างไฟล์:
express-jwt-workshop/
server.mjs
.env
.env.example
.gitignore
package.json
package-lock.json
#สร้าง secret และไฟล์ .env
สร้างไฟล์ .env ด้วยคำสั่งนี้ คำสั่งสร้าง secret จาก random bytes ไม่ใช้คำที่เดาได้:
node -e "const fs=require('node:fs'); const c=require('node:crypto'); fs.writeFileSync('.env','PORT=3000\nJWT_SECRET='+c.randomBytes(32).toString('hex')+'\n',{flag:'wx',mode:0o600})"
flag: 'wx' ป้องกันการเขียนทับ .env ที่มีอยู่แล้ว หากมีไฟล์เดิม ให้แก้ไขค่าอย่างตั้งใจโดยไม่สร้าง secret ใหม่ทุกครั้งที่เปิด server
ตัวอย่าง .env.example:
PORT=3000
JWT_SECRET=REPLACE_WITH_64_RANDOM_HEX_CHARACTERS
ตัวอย่าง .gitignore:
node_modules/
.env
.env.*
!.env.example
โค้ดตรวจว่า secret เป็น hex 64 ตัวอักษรและแปลงเป็น key ขนาด 32 bytes หากไม่ถูกต้อง server จะหยุดทันที ไม่มี fallback secret ใน source code
#4. ออกแบบ Endpoint
| Method | Endpoint | การเข้าถึง | ผลลัพธ์หลัก |
|---|---|---|---|
| GET | /health |
สาธารณะ | ตรวจสอบสถานะ server |
| POST | /auth/register |
สาธารณะ | สมัครสมาชิก |
| POST | /auth/login |
สาธารณะ | รับ access token |
| GET | /me |
ต้องมี token | อ่านบัญชีของตนเอง |
| GET | /admin/users |
ต้องเป็น admin | อ่านรายชื่อบัญชี |
| POST | /notes |
ต้องมี token | สร้างบันทึกของตนเอง |
| GET | /notes |
ต้องมี token | อ่านรายการบันทึกของตนเอง |
| GET | /notes/:id |
ต้องเป็นเจ้าของ | อ่านบันทึกตาม ID |
การสมัครสมาชิกกำหนด role: 'user' ฝั่ง server เสมอ การส่ง role: 'admin' หรือ ownerId จาก client ไม่สามารถเพิ่มสิทธิ์หรือเปลี่ยนเจ้าของข้อมูลได้
#5. เขียน server.mjs
คัดลอกโค้ดทั้งหมดต่อไปนี้ลงในไฟล์ server.mjs:
import express from 'express';
import helmet from 'helmet';
import { rateLimit } from 'express-rate-limit';
import jwt from 'jsonwebtoken';
import { randomBytes, randomUUID, scrypt, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const derive = promisify(scrypt);
const keyHex = process.env.JWT_SECRET;
if (!/^[a-f0-9]{64}$/i.test(keyHex ?? '')) {
throw new Error('JWT_SECRET must be 32 random bytes encoded as 64 hex characters');
}
const key = Buffer.from(keyHex, 'hex');
const issuer = 'express-jwt-workshop';
const audience = 'notes-api';
const ttl = 900;
const cost = { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
const users = new Map(); // Workshop only: replace with a database
const notes = new Map();
async function hashPassword(password) {
const salt = randomBytes(16).toString('hex');
const hash = await derive(password, salt, 64, cost);
return { salt, hash: hash.toString('hex') };
}
async function checkPassword(password, stored) {
const actual = await derive(password, stored.salt, 64, cost);
return timingSafeEqual(actual, Buffer.from(stored.hash, 'hex'));
}
const dummyHash = await hashPassword(randomBytes(32).toString('hex'));
const publicUser = ({ id, email, role }) => ({ id, email, role });
function credentials(body) {
const email = typeof body?.email === 'string' ? body.email.trim().toLowerCase() : '';
const password = body?.password;
const valid = email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
&& typeof password === 'string' && [...password].length >= 12
&& [...password].length <= 128 && Buffer.byteLength(password, 'utf8') <= 512;
return valid ? { email, password } : null;
}
const app = express();
app.disable('x-powered-by');
app.use(helmet());
app.use(express.json({ limit: '16kb', strict: true }));
app.use(rateLimit({ windowMs: 60_000, limit: 100, standardHeaders: 'draft-8',
legacyHeaders: false, message: { error: 'too_many_requests' } }));
const authLimiter = rateLimit({ windowMs: 15 * 60_000, limit: 20,
standardHeaders: 'draft-8', legacyHeaders: false,
message: { error: 'too_many_auth_requests' } });
app.use('/auth', authLimiter);
app.use('/auth', (_req, res, next) => {
res.set('Cache-Control', 'no-store');
next();
});
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
app.post('/auth/register', async (req, res) => {
const input = credentials(req.body);
if (!input) return res.status(400).json({ error: 'invalid_email_or_password' });
if (users.has(input.email)) return res.status(409).json({ error: 'email_in_use' });
const stored = await hashPassword(input.password);
// Check again after await to prevent two concurrent registrations overwriting a user
if (users.has(input.email)) return res.status(409).json({ error: 'email_in_use' });
const user = { id: randomUUID(), email: input.email, role: 'user', ...stored };
users.set(user.email, user); // Never take role or id from req.body
res.status(201).json({ user: publicUser(user) });
});
app.post('/auth/login', async (req, res) => {
const input = credentials(req.body);
if (!input) return res.status(400).json({ error: 'invalid_email_or_password' });
const user = users.get(input.email);
const matched = await checkPassword(input.password, user ?? dummyHash);
if (!user || !matched) return res.status(401).json({ error: 'invalid_credentials' });
const accessToken = jwt.sign({ token_use: 'access' }, key, {
algorithm: 'HS256', subject: user.id, issuer, audience, expiresIn: ttl
});
res.json({ accessToken, tokenType: 'Bearer', expiresIn: ttl });
});
function authenticate(req, res, next) {
const match = /^Bearer ([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/i
.exec(req.get('authorization') ?? '');
if (!match) {
res.set('WWW-Authenticate', 'Bearer');
return res.status(401).json({ error: 'missing_or_invalid_token' });
}
let payload;
try {
payload = jwt.verify(match[1], key, { algorithms: ['HS256'], issuer, audience });
} catch (err) {
res.set('WWW-Authenticate', 'Bearer error="invalid_token"');
return res.status(401).json({ error:
err.name === 'TokenExpiredError' ? 'token_expired' : 'invalid_token' });
}
if (typeof payload !== 'object' || payload.token_use !== 'access'
|| typeof payload.sub !== 'string' || !Number.isInteger(payload.exp)) {
return res.status(401).json({ error: 'invalid_token_claims' });
}
const user = [...users.values()].find(u => u.id === payload.sub);
if (!user) return res.status(401).json({ error: 'invalid_user' });
req.user = user; // Current server-side role, not client-supplied permissions
res.set('Cache-Control', 'no-store');
next();
}
const requireRole = role => (req, res, next) => {
if (req.user.role !== role) return res.status(403).json({ error: 'forbidden' });
next();
};
app.get('/me', authenticate, (req, res) => res.json({ user: publicUser(req.user) }));
app.get('/admin/users', authenticate, requireRole('admin'), (_req, res) => {
res.json({ users: [...users.values()].map(publicUser) });
});
app.get('/notes', authenticate, (req, res) => {
res.json({ notes: [...notes.values()].filter(n => n.ownerId === req.user.id) });
});
app.post('/notes', authenticate, (req, res) => {
const title = req.body?.title;
if (typeof title !== 'string' || !title.trim() || title.length > 200) {
return res.status(400).json({ error: 'invalid_title' });
}
const note = { id: randomUUID(), title: title.trim(), ownerId: req.user.id };
notes.set(note.id, note);
res.status(201).json({ note });
});
app.get('/notes/:id', authenticate, (req, res) => {
const note = notes.get(req.params.id);
if (!note || note.ownerId !== req.user.id) {
return res.status(404).json({ error: 'note_not_found' });
}
res.json({ note });
});
app.use((_req, res) => res.status(404).json({ error: 'not_found' }));
app.use((err, _req, res, _next) => {
if (err.type === 'entity.parse.failed') return res.status(400).json({ error: 'invalid_json' });
if (err.type === 'entity.too.large') return res.status(413).json({ error: 'body_too_large' });
console.error('Unhandled error:', err.name); // Do not log passwords or bearer tokens
res.status(500).json({ error: 'internal_server_error' });
});
const port = Number(process.env.PORT ?? 3000);
app.listen(port, '127.0.0.1', () => console.log(`API ready: http://127.0.0.1:${port}`));
เริ่มทำงาน:
npm run dev
Server ฟังที่ 127.0.0.1:3000 เหมาะสำหรับทำแล็บในเครื่อง หากใช้ container ต้องปรับ listen address และ port mapping ให้ตรงกับสภาพแวดล้อม
#จุดสำคัญของ implementation
scryptทำงานแบบ async และใช้ random salt แยกแต่ละบัญชี ค่าN=131072, r=8, p=1ตรงกับหนึ่งในค่าที่ OWASP ระบุสำหรับ scrypt;maxmemเป็นเพดานหน่วยความจำที่ Node อนุญาต ไม่ใช่หน่วยความจำที่ใช้จริงทุกครั้ง- รหัสผ่านต้องมี 12–128 Unicode code points และไม่เกิน 512 bytes ซึ่งเป็นนโยบายของตัวอย่างนี้
timingSafeEqualใช้เปรียบเทียบผล hash ที่มีขนาดเท่ากัน ไม่ได้ทำให้ทุกส่วนของ login มีเวลาคงที่- บัญชีที่ไม่มีอยู่ยังคำนวณ password hash กับ dummy hash เพื่อลดความแตกต่างของเวลา แต่การสมัครสมาชิกยังตอบ
409เมื่อ email ซ้ำ jwt.sign()กำหนด algorithm และ claims;jwt.verify()ใช้ allowlist ของ algorithm พร้อม issuer/audience ไม่ใช้jwt.decode()เพื่อตัดสินสิทธิ์- ตัวอย่างบังคับว่าต้องมี
expแม้ token ที่ไม่มีexpอาจตรวจลายเซ็นผ่านได้ในไลบรารี - อ่าน role ล่าสุดจากข้อมูล server แทนการเชื่อข้อมูลสิทธิ์ที่ client ส่งมา
- มี rate limit รวม 100 requests/นาที/IP และ 20 requests/15 นาที/IP สำหรับ
/authเป็นค่าตั้งต้นเพื่อทำแล็บ - ตอบ
404ทั้งกรณีไม่มีบันทึกและไม่ใช่เจ้าของ เพื่อไม่เปิดเผยว่าบันทึกของผู้อื่นมีอยู่หรือไม่ - Error middleware ไม่ส่ง stack trace, password หรือ secret กลับไปยัง client
scrypt ใช้ทรัพยากรสูง จึงต้องวัดประสิทธิภาพและจำกัดงานพร้อมกันก่อนรองรับผู้ใช้จำนวนมาก อ่านแนวทางที่ OWASP Password Storage Cheat Sheet และ Node.js Crypto
#6. ทดสอบด้วย curl
คำสั่งหลายบรรทัดด้านล่างใช้ Bash บน Linux/macOS หากใช้ Windows CMD ให้ใช้ curl.exe และปรับเป็นบรรทัดเดียว หรือทดสอบผ่าน Postman โดยเลือก Body → raw → JSON
#6.1 ตรวจสอบสถานะ
curl -i http://127.0.0.1:3000/health
คาดหวัง 200 OK และ {"status":"ok"}
#6.2 สมัครสมาชิก
curl -i -X POST http://127.0.0.1:3000/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"student@example.com","password":"WorkshopPass123!"}'
คาดหวัง 201 Created และข้อมูลบัญชีที่ไม่มี password hash รหัสผ่านนี้เป็นตัวอย่างสำหรับแล็บ ไม่ควรใช้เป็นรหัสผ่านจริง
#6.3 เข้าสู่ระบบและเก็บ token
LOGIN_JSON=$(curl -sS -X POST http://127.0.0.1:3000/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"student@example.com","password":"WorkshopPass123!"}')
TOKEN=$(printf '%s' "$LOGIN_JSON" | node -e \
"let s='';process.stdin.on('data',d=>s+=d);process.stdin.on('end',()=>{const j=JSON.parse(s);if(!j.accessToken)process.exit(1);process.stdout.write(j.accessToken)})")
คำสั่งดึง accessToken จาก JSON โดยไม่ต้องติดตั้ง jq หาก login ไม่สำเร็จ ให้ตรวจ response ก่อนทำขั้นตอนถัดไป
ตัวอย่าง response โดยแทนค่า token จริงด้วยข้อความประกอบ:
{
"accessToken": "<JWT_ACCESS_TOKEN>",
"tokenType": "Bearer",
"expiresIn": 900
}
#6.4 เรียก API ที่ต้องยืนยันตัวตน
curl -i http://127.0.0.1:3000/me \
-H "Authorization: Bearer $TOKEN"
คาดหวัง 200 OK ลองเรียกโดยไม่มี header เพื่อเปรียบเทียบกับ 401 Unauthorized
#6.5 สร้างและอ่านบันทึก
curl -i -X POST http://127.0.0.1:3000/notes \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"title":"เรียน Express API กับ JWT"}'
curl -i http://127.0.0.1:3000/notes \
-H "Authorization: Bearer $TOKEN"
คัดลอก note.id ที่ได้รับมาแทน <NOTE_ID>:
curl -i http://127.0.0.1:3000/notes/<NOTE_ID> \
-H "Authorization: Bearer $TOKEN"
#6.6 ทดสอบสิทธิ์ผู้ดูแล
curl -i http://127.0.0.1:3000/admin/users \
-H "Authorization: Bearer $TOKEN"
คาดหวัง 403 Forbidden เพราะบัญชีจาก register เป็น user ทุกบัญชี Workshop นี้ยังไม่ได้สร้าง admin สำหรับกรณีสำเร็จ เมื่อเชื่อมฐานข้อมูล ให้สร้างหรือแต่งตั้ง admin ผ่านกระบวนการบริหารที่จำกัดสิทธิ์ อย่าเพิ่ม route สาธารณะเพื่อเปลี่ยน role
#7. ทดสอบด้วย Postman
- สร้าง collection ชื่อ
Express JWT Workshop - เพิ่ม environment variable
baseUrlเท่ากับhttp://127.0.0.1:3000 - ส่ง request สมัครสมาชิกและ login ด้วย JSON ตามตัวอย่าง
- คัดลอก access token ไปใส่ Authorization → Bearer Token สำหรับ
/meและ/notes - เก็บ token ของผู้ใช้คนที่สองแยกกันเพื่อทดสอบการเข้าถึงข้ามบัญชี
Post-response script ของ request login สำหรับบันทึก token:
pm.test('Login succeeds', () => pm.response.to.have.status(200));
if (pm.response.code === 200) {
const body = pm.response.json();
pm.environment.set('accessToken', body.accessToken);
} else {
pm.environment.unset('accessToken');
}
ใช้ {{accessToken}} ในช่อง Bearer Token หลีกเลี่ยงการนำ token จริงใส่ใน collection ที่แชร์หรือ publish
#8. ตารางกรณีทดสอบที่ควรครอบคลุม
| กรณี | HTTP status ที่คาดหวัง | สิ่งที่ต้องตรวจเพิ่มเติม |
|---|---|---|
| สมัครสมาชิกสำเร็จ | 201 | ไม่มี password หรือ hash ใน response |
| ส่ง role เป็น admin ตอนสมัคร | 201 | role ที่บันทึกยังเป็น user |
| สมัคร email ซ้ำ | 409 | บัญชีเดิมไม่ถูกเขียนทับ |
| email หรือ password ไม่ผ่าน validation | 400 | ไม่มีบัญชีใหม่ |
| login ถูกต้อง | 200 | ได้ token อายุ 900 วินาที |
| password ผิด | 401 | ไม่มี token |
| ไม่ส่ง token | 401 | route ถูกปฏิเสธ |
| token หมดอายุ | 401 | error เป็น token_expired |
| แก้ payload โดยไม่ลงลายเซ็นใหม่ | 401 | ไม่เพิ่มสิทธิ์ |
| ใช้ลายเซ็นหรือ key ผิด | 401 | ไม่เข้าถึงข้อมูล |
| issuer/audience ผิด | 401 | ไม่ยอมรับ token สำหรับระบบอื่น |
| ใช้ algorithm ที่ไม่อนุญาต | 401 | เช่น HS384 แทน HS256 |
| token ไม่มี exp | 401 | ต้องมีเวลาหมดอายุ |
| user เรียก admin route | 403 | ไม่ส่งรายชื่อบัญชี |
| owner อ่านบันทึกของตนเอง | 200 | ID และ ownerId ถูกต้อง |
| user B อ่านบันทึกของ user A | 404 | ไม่ส่งเนื้อหาบันทึก |
| user B เรียกรายการบันทึก | 200 | ไม่มีบันทึกของ user A |
| ส่ง ownerId ปลอมตอนสร้าง | 201 | ownerId มาจาก token/user ฝั่ง server |
| JSON ผิดรูปแบบ | 400 | ไม่ส่ง stack trace |
| request body เกิน 16kb | 413 | ปฏิเสธ payload |
| เรียก auth เกิน limit | 429 | ปฏิเสธ request เพิ่มเติม |
ผลการตรวจสอบตัวอย่าง: ทดสอบผ่าน HTTP จริง 38 requests ด้วย Node.js 24.19.0 ครอบคลุม register/login, สิทธิ์, การแยกข้อมูล, token ที่ไม่ถูกต้อง, JSON ผิดรูปแบบ และ rate limit พร้อม assertion เพิ่มเติมเรื่องไม่เปิดเผย hash, ไม่รับ role/ownerId ที่ปลอมมา รายการ body เกิน 16kb ในตารางเป็นกรณีแนะนำเพิ่มเติม ไม่ได้รวมใน 38 requests ดังกล่าว
#9. Authentication, Authorization และ Ownership
การมี JWT ที่ถูกต้องตอบเพียงว่า request ผูกกับบัญชีใด ยังต้องตรวจว่าบัญชีทำอะไรได้บ้าง:
| ชั้นการตรวจ | คำถาม | ตัวอย่างในโค้ด |
|---|---|---|
| Authentication | ผู้เรียกเป็นใคร | authenticate ตรวจ token และค้นหาผู้ใช้ |
| Role authorization | บัญชีมีสิทธิ์ทำงานนี้หรือไม่ | requireRole('admin') |
| Resource ownership | บัญชีเข้าถึงข้อมูลชิ้นนี้ได้หรือไม่ | เปรียบเทียบ note.ownerId กับ req.user.id |
แม้ token มีลายเซ็นถูกต้อง ก็ห้ามใช้ ID จาก URL แล้วส่งข้อมูลทันทีโดยไม่ตรวจเจ้าของ เมื่อเปลี่ยนเป็น SQL ให้ค้นหาแบบ WHERE id = ? AND owner_id = ? ด้วย parameterized query
#10. การนำไปต่อยอดเป็นระบบใช้งานจริง
#ฐานข้อมูลและสิทธิ์
ใช้ PostgreSQL หรือฐานข้อมูลที่เหมาะกับระบบ กำหนด unique constraint สำหรับ email และ foreign key สำหรับ owner ตรวจ unique conflict ฝั่งฐานข้อมูลด้วย เพราะการตรวจ email ก่อน insert อย่างเดียวไม่ป้องกัน race condition
แยกโค้ดเป็น routes, controllers, services และ repositories เมื่อโครงการใหญ่ขึ้น ตรวจสถานะบัญชีและการเปลี่ยนสิทธิ์จากข้อมูลที่เชื่อถือได้เสมอ
#Access token, refresh token และ logout
ตัวอย่างนี้มีเฉพาะ access token อายุ 15 นาที ผู้ใช้ต้อง login ใหม่เมื่อหมดอายุ การลบ token ออกจาก client เป็นการ logout ฝั่ง client แต่ token ที่ถูกคัดลอกไปก่อนหน้านั้นยังอาจใช้งานได้จนหมดอายุ
หากต้องการ logout ทุกอุปกรณ์หรือเพิกถอน token ทันที ให้ออกแบบ session record/token version หรือ denylist ของ jti พร้อมกำหนดนโยบายอย่างชัดเจน Refresh token ควรมีอายุและวัตถุประสงค์แยกจาก access token มี rotation และตรวจจับการใช้ token เก่าซ้ำ หลีกเลี่ยงการนำ access token ที่หมดอายุมาออก token ใหม่โดยไม่ตรวจ credential ของ refresh session
#การเก็บ token ในเว็บ
สำหรับแล็บใช้ Postman หรือ curl เมื่อพัฒนา browser client ให้เลือกแนวทางตาม architecture เช่นเก็บ access token ใน memory หรือใช้ backend-for-frontend กับ cookie แบบ HttpOnly, Secure และ SameSite ที่เหมาะสม หากใช้ cookie สำหรับ authentication ต้องออกแบบการป้องกัน CSRF ด้วย
ไม่ควรเก็บ long-lived token ใน localStorage โดยไม่มีการพิจารณาความเสี่ยงจาก XSS และไม่ส่ง token ผ่าน query string เพราะอาจถูกบันทึกใน log หรือประวัติ URL
#Deployment และการรองรับหลาย instance
- ใช้ HTTPS และ secret manager พร้อมแผน key rotation
- HS256 ให้ผู้ที่ถือ shared secret ลงลายเซ็นได้ด้วย หากหลายบริการควรมีเฉพาะสิทธิ์ตรวจสอบ ให้พิจารณา asymmetric signing พร้อม public key/JWKS และกำหนด algorithm ให้ชัดเจน
- ใช้ shared rate-limit store เมื่อติดตั้งหลาย instance;
Mapและ limiter ในตัวอย่างอยู่เฉพาะ process - ตั้งค่า
trust proxyให้ตรงกับ reverse proxy ที่เชื่อถือได้จริง เพื่อให้ limiter เห็น client IP ที่ถูกต้อง ไม่ตั้งเป็นtrueโดยไม่มีการตรวจ topology - เพิ่ม login protection ตามบัญชี, monitoring และ concurrency limit สำหรับ password hashing
- ตั้ง CORS เฉพาะ origin ที่จำเป็นเมื่อมี browser frontend; CORS ไม่ใช่กลไกยืนยันตัวตน
- ไม่บันทึก Authorization header, password หรือ token ลง log
#11. แบบฝึกหัดสำหรับผู้เรียน
- เพิ่ม update/delete note โดยตรวจเจ้าของข้อมูลทุกครั้ง
- เปลี่ยนจาก Map เป็น PostgreSQL และทดสอบข้อมูลยังอยู่หลัง restart
- เขียน integration tests สำหรับ JWT หมดอายุ, key ผิด และข้อมูลข้ามบัญชี
- สร้าง admin ผ่าน seed ที่จำกัดการใช้งาน แล้วทดสอบทั้ง 200 และ 403
- ออกแบบ refresh token rotation พร้อมกรณีทดสอบ reuse detection
- เพิ่ม OpenAPI specification ที่ใช้ security scheme แบบ HTTP Bearer
#แหล่งอ้างอิง
- Express 5 migration guide
- jsonwebtoken: sign, verify และตัวเลือกการตรวจ claims
- RFC 7519: JSON Web Token
- Node.js Crypto: scrypt และ timingSafeEqual
- OWASP Password Storage Cheat Sheet
- express-rate-limit configuration
บทความและโค้ดตัวอย่างนี้เรียบเรียงเพื่อ Workshop โดยใช้เอกสารทางการประกอบ และแยกส่วนที่ทำงานได้ในแล็บออกจากงานที่ต้องเพิ่มก่อนใช้งานจริง