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

#ขั้นตอนการใช้งาน

  1. Client ส่ง email และ password ไปที่ /auth/login
  2. Server ตรวจ password hash แล้วออก access token อายุ 15 นาที
  3. Client ส่ง Authorization: Bearer <token> เมื่อเรียก API
  4. Middleware ตรวจลายเซ็น, algorithm, issuer, audience และวันหมดอายุ
  5. 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

  1. สร้าง collection ชื่อ Express JWT Workshop
  2. เพิ่ม environment variable baseUrl เท่ากับ http://127.0.0.1:3000
  3. ส่ง request สมัครสมาชิกและ login ด้วย JSON ตามตัวอย่าง
  4. คัดลอก access token ไปใส่ Authorization → Bearer Token สำหรับ /me และ /notes
  5. เก็บ 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. แบบฝึกหัดสำหรับผู้เรียน

  1. เพิ่ม update/delete note โดยตรวจเจ้าของข้อมูลทุกครั้ง
  2. เปลี่ยนจาก Map เป็น PostgreSQL และทดสอบข้อมูลยังอยู่หลัง restart
  3. เขียน integration tests สำหรับ JWT หมดอายุ, key ผิด และข้อมูลข้ามบัญชี
  4. สร้าง admin ผ่าน seed ที่จำกัดการใช้งาน แล้วทดสอบทั้ง 200 และ 403
  5. ออกแบบ refresh token rotation พร้อมกรณีทดสอบ reuse detection
  6. เพิ่ม OpenAPI specification ที่ใช้ security scheme แบบ HTTP Bearer

#แหล่งอ้างอิง

บทความและโค้ดตัวอย่างนี้เรียบเรียงเพื่อ Workshop โดยใช้เอกสารทางการประกอบ และแยกส่วนที่ทำงานได้ในแล็บออกจากงานที่ต้องเพิ่มก่อนใช้งานจริง