ออกแบบ API key สำหรับการเรียกกัน service to service ให้เบาและยังถูกต้อง
วันหนึ่งจะมีอีกทีมมาขอเรียก service ของคุณ และคุณต้องรู้ให้ได้ว่าใครเป็นคนยิงเข้ามา
ตัวเลือกที่คนส่วนใหญ่หยิบมีสองขั้ว — ยัดค่าสุ่ม uuid/hash ไว้ใน env var แล้วเทียบสตริง ซึ่งใช้ได้จริงจนถึงวันที่ key หลุด หรือลาก OAuth2 มาทั้งชุด ซึ่งซับซ้อนกว่าสิ่งที่กำลังจะปกป้องหลายเท่า โพสต์นี้คือทางกลาง
TL;DR
ตารางเดียว 6 คอลัมน์ กับฟังก์ชันสิบบรรทัด
ของเล็กใครก็เขียนได้ ประเด็นอยู่ที่ว่าตัวนี้ไม่ทิ้งหนี้ไว้ให้ตัวเอง เพราะเก็บ 3 อย่างที่ราคาวันนี้เกือบศูนย์ แต่แก้ทีหลังแพง
- เก็บ hash ไม่เก็บตัว key
- การ revoke ให้เก็บเป็นเวลา แล้วกรองมันในตัว query ไม่ใช่ในโค้ด
- อย่าใส่ UNIQUE บนคอลัมน์
consumer
และตัดออก 4 อย่างที่ยังไม่ต้องมีใน Lite design — scopes, cache, CLI ออก key และ last_used_at
ขอบเขต Lite design
ทั้งโพสต์พูดถึงกรณีเดียวคือ service to service ไม่ใช่ key ที่User ปลายทางกดสร้างเองจากหน้าเว็บ
สมมติฐานสามข้อนี้คือเหตุผลทั้งหมดที่ทำให้ design เล็กได้ขนาดนี้
- ผู้ถือ key คือระบบ ไม่ใช่คน — key นอนอยู่ใน secret manager ของอีกฝั่ง ไม่มีใครต้องจำ
- จำนวน consumer นับด้วยนิ้วมือได้ — consumer คือระบบฝั่งที่มาเรียกเรา ซึ่งในกรณีนี้คือทีมที่รู้จักชื่อกันทุกราย ไม่ใช่คนที่สมัครเข้ามาเองได้
- คนออก key คือคุณ — เป็นขั้นตอนที่มีคนกดเอง ไม่ใช่ flow อัตโนมัติ
ถ้าเป็น public API ที่ใครก็สมัครได้ สมมติฐานพังทั้งสามข้อ และจะต้องมีการ rate limit ต่อราย, หน้าจัดการ key, วันหมดอายุ และการแยกสิทธิ์ ซึ่งอยู่นอกขอบเขตนี้
และถ้าเบากว่านั้นอีก คือมี consumer แค่หนึ่งถึงสามรายที่แทบไม่เปลี่ยน ก็ไม่ต้องมีตาราง เก็บ hash ไว้ใน env ไปเลย แล้วโหลดเข้า Map ตอน boot พอ ที่เหลือในโพสต์นี้ใช้ได้เหมือนเดิมทั้งหมด ต่างแค่ที่มาของข้อมูล — แลกกับข้อเดียวคือรายชื่อ key ถูกอ่านตอน service เริ่มทำงาน การถอน key ออกจึงต้อง deploy ใหม่เท่านั้น
ตัวตัดสินมีข้อเดียว: ถ้า key รั่วตอนตีสาม รับได้ไหมที่จะต้อง deploy เพื่อปิดมัน
ถ้าตอนนี้ service ยังไม่มีอะไรกั้นเลย อย่ารอทำแบบเต็มให้เสร็จ ทำแบบ env ปิดช่องไปก่อนแล้วค่อยย้าย — ถ้าใช้สูตร hash เดียวกัน การย้ายไม่ต้องออก key ใหม่สักตัว
แล้วเมื่อไรที่ต้องการ feature ที่หนักกว่านี้
design นี้จงใจไม่มี scopes, cache, CLI ออก key และคอลัมน์อย่าง expires_at — แต่ละอย่างมีสัญญาณบอกเองว่าเมื่อไหร่ถึงเวลา คือ
- scopes เมื่อ endpoint หลัง guard เริ่มเสี่ยงไม่เท่ากัน ตัวที่เสียเงินต่อครั้งปนกับตัวที่แค่อ่านข้อมูล
- cache เมื่อ DB lookup เริ่มกินเวลาจนเห็นในกราฟ latency
- CLI เมื่อออก key บ่อยจนเริ่มมีคนลัดขั้นตอน
-
expires_at/issued_byเมื่อต้องกำหนดวันหมดอายุหรือต้องตอบ audit ว่าใครออก key ไหนเมื่อไหร่
ทั้งหมดเพิ่มทีหลังได้โดยไม่กระทบของเดิม ไม่ต้องออก key ใหม่ มีอย่างเดียวที่ย้อนแก้ยากคือตำแหน่งของ UNIQUE ในตาราง ซึ่งเป็นเหตุผลที่หัวข้อถัดไปให้พื้นที่กับมันมากกว่าเรื่องอื่น
การเก็บข้อมูล
CREATE TABLE client_api_key (
id INT AUTO_INCREMENT PRIMARY KEY,
consumer VARCHAR(100) NOT NULL, -- ห้าม UNIQUE (ดูด้านล่าง)
key_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex ของ key เต็ม
revoked_at DATETIME NULL,
description VARCHAR(255), -- เลขตั๋ว + ใครเป็นคนขอ
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
หกคอลัมน์นี้ดูธรรมดา แต่มีสามจุดที่เป็นการตัดสินใจ ไม่ใช่ความบังเอิญ
เก็บ hash ไม่เก็บตัว key
ถ้าเก็บ key ตรงๆ ใครก็ตามที่อ่าน DB ได้จะกลายเป็นเจ้าของ key ทั้งหมดทันที และนั่นไม่ใช่แค่คนที่ตั้งใจโขมย — เป็นไปได้ว่ามี case แบบ Dev dump ของ prod ค้างอยู่บนเครื่อง dev เป็นเดือน เพราะจะ debug อะไรสักอย่างแล้วลืมลบ มันก็เป็นความเสี่ยงเหมือนกัน
เก็บ sha256(key) แทน แล้วเวลา authenticate ก็ hash ค่าที่รับมาแล้วค้นด้วย hash ไม่ต้องใช้ bcrypt เหมือนตอนเราเก็บ password เพราะค่าที่สุ่มมา 256 บิตไม่มีอะไรให้เดา และ salt จะทำให้ค้นด้วย index ไม่ได้
ต้นทุนของการทำถูกวันนี้คือ code หนึ่งบรรทัด ต้นทุนของการมาแก้ทีหลังคือ key ที่เคยเก็บเป็น plain text ต้องถือว่ารั่วไปแล้วทุกตัว จึงต้องออกใหม่ทั้งหมดและนัดกับ consumer ทุกรายให้เปลี่ยนพร้อมกัน
การ revoke ให้เก็บเป็นเวลา
เก็บเป็น timestamp เพราะตอบได้ทั้งว่า ถูก revoke ไหม และ เมื่อไหร่ ซึ่งข้อหลังเป็นคำถามแรกในวันที่มีเรื่องให้ต้องสอบสวน
แต่เรื่องที่สำคัญกว่าชื่อคอลัมน์คือเอาเงื่อนไขนี้ไปวางไว้ตรงไหน
SELECT consumer FROM client_api_key WHERE key_hash = ? AND revoked_at IS NULL
ท่อน AND revoked_at IS NULL ต้องอยู่ในตัว query ไม่ใช่กรองในโค้ดทีหลัง เพราะ query ที่ขาดท่อนนี้จะคืนค่าถูกต้อง เทสผ่านหมด และไม่มีอะไรให้สะดุดตาตอน review — จนถึงวันที่กด revoke แล้วพบว่า key นั้นยังใช้ได้อยู่ ผมเคยปล่อยแบบนี้ผ่านมาแล้ว
และถ้ากรองในโค้ด วันหนึ่งจะมีคนเขียน query ที่สองโดยไม่รู้ว่าต้องกรอง
อย่าใส่ UNIQUE บนคอลัมน์ consumer
ครั้งแรกที่ผมทำตารางนี้ ผมใส่ UNIQUE บน consumer ไปด้วยความรู้สึกว่ามันสะอาดดี หนึ่ง consumer หนึ่ง key ฐานข้อมูลช่วยการันตีให้ ไม่มีแถวขยะ
สิ่งที่ UNIQUE ตรงนี้แปลว่าคุณหมุน key (key rotation) ไม่ได้ เพราะ key rotation ที่ปลอดภัยต้องมีช่วงที่ key เก่ากับใหม่ใช้ได้พร้อมกัน ถ้าถือได้ key เดียว ทางเดียวที่เหลือคือลบของเก่าแล้ว insert ของใหม่ แล้วทุก request ที่ยิงเข้ามาระหว่างนั้นจะโดน 401
ช่วงเวลานั้นยาวแค่ไหนไม่ได้ขึ้นกับคุณ มันจบเมื่ออีกทีม deploy ค่าใหม่เสร็จ ซึ่งอาจเป็นบ่ายพรุ่งนี้หรือหลัง sprint นี้ — และมันจะเจ็บที่สุดในวันที่ key รั่วแล้วต้องเปลี่ยนเดี๋ยวนี้ ตอนนั้น UNIQUE จะบังคับให้เลือกระหว่างปล่อยให้ key ที่รั่วใช้ได้ต่ออีกพัก กับทำให้ consumer ล่ม ทั้งที่มันไม่ควรเป็นทางเลือกที่ต้องเจอตั้งแต่แรก
ตุ้นทุนของการทำถูกวันนี้คือไม่พิมพ์คำว่า UNIQUE ตุ้นทุนของการมาแก้ทีหลังคือ drop index บนตารางที่มี traffic วิ่งอยู่
เมื่อปล่อยให้ consumer ซ้ำได้แล้ว key rotation จะเหลือแค่นี้ ไม่มี downtime และ Rollback ได้
INSERT key ใหม่ → consumer สลับไปใช้ key ใหม่ → ยืนยันว่า key เก่าไม่มี traffic แล้ว → revoke key เก่า
key rotation เป็นคุณสมบัติที่ดีในงานความปลอดภัยของระบบที่จะมีได้
รูปแบบของ key
acme_prod_9b1d4e7a… (64 hex)
ส่วนนำหน้า acme_<env>_ ไม่มีผลต่อการตรวจสอบ เราไม่ได้ parse มัน มันมีไว้สองอย่าง
- ตั้ง pattern
acme_(dev|staging|prod)_[0-9a-f]{64}ใน secret scanner แล้วดักตอน commit ก่อน key จะขึ้นไปอยู่บน remote ตลอดกาล - เวลามีคนแปะ key มาในแชทว่า "อันนี้ใช้ไม่ได้" จะเห็นคำตอบจากตัวอักษรสิบตัวแรกโดยไม่ต้องถามกลับ
ต่อกับ framework
request → จุดต่อ (อ่าน header) → resolver (hash → query DB) → ผ่าน หรือ 401
แยกเป็นสองชิ้น — resolver ฟังก์ชันเดียวที่รับ key แล้วบอกว่าเป็นของใคร หรือคืน null ถ้าใช้ไม่ได้ และ จุดต่อ ที่ framework เรียก ซึ่งจะชื่อ middleware, guard หรือ interceptor ก็แล้วแต่ค่าย แยกแบบนี้แล้วเปลี่ยนวิธีเก็บ key ทีหลังได้โดยไม่ต้องแตะโค้ดที่ผูกกับ framework
สัญญา 5 ข้อที่ห้ามเพี้ยน
5 ข้อนี้ต้องเหมือนกันทุกที่ ไม่งั้น key ที่ออกจากที่หนึ่งจะใช้กับอีกที่ไม่ได้ หรือ revoke แล้วไม่ได้
-
hash เป็น sha256 hex ตัวพิมพ์เล็ก ไม่มี newline ต่อท้าย — สิ่งเดียวที่ทำให้ key เดิมยังใช้ได้ตอนย้ายวิธีเก็บ ระวัง
echoใน shell ที่เติม\nให้โดยไม่บอก - Filter "revoked_at IS NULL" ที่ชั้น lookup — ตามหัวข้อที่แล้ว
- Response Http status 401 ทุกเคส — ไม่มี header, key ไม่รู้จัก, key ถูก revoke และ body ไม่บอกเหตุผล ระวังว่าบาง framework ตีความ guard ที่คืนค่า false เป็น 403 ซึ่งผิดความหมาย (401 คือ "ไม่รู้ว่าคุณคือใคร" ส่วน 403 คือ "รู้ว่าคุณคือใคร แต่เข้าตรงนี้ไม่ได้")
- log ชื่อ consumer ทุกครั้งที่ผ่าน และห้าม log ค่า key — รวมถึงตอน error ซึ่งเป็นตอนที่คนเผลอที่สุด บรรทัดนี้จะเป็นแหล่งเดียวที่ตอบได้ว่า key ไหนยังมีคนใช้
- แปะ consumer ลง request context — ให้ layer ถัดไปใช้ต่อโดยไม่ต้อง query ซ้ำ
Resolver
const resolve = async (key) => {
const hash = createHash('sha256').update(key).digest('hex')
const [row] = await db.query(
'SELECT consumer FROM client_api_key WHERE key_hash = ? AND revoked_at IS NULL LIMIT 1',
[hash],
)
return row ?? null // { consumer } หรือ null
}
เท่านี้จริง ๆ และไม่ต้องมี timingSafeEqual เพราะเราค้นด้วย hash ผ่าน unique index ไม่ได้เทียบสตริงทีละไบต์ จึงไม่มีขั้นตอนไหนให้จับเวลา
จุดต่อ
const key = req.headers['x-api-key']
const identity = key ? await resolve(key) : null
if (!identity) {
logger.warn('api key rejected') // ห้าม log ค่า key
return unauthorized() // 401 และหยุดตรงนี้
}
logger.info(`api key accepted consumer=${identity.consumer}`)
attachToContext(identity)
สามเรื่องที่ framework อาจไม่ได้จัดการให้
- header ซ้ำได้ ค่าที่ได้จึงเป็น array ได้ ต้องเช็คก่อนใช้เอง
- ถ้าปฏิเสธแล้วไม่หยุดตรงนั้น request ที่ควรถูกปฏิเสธจะหลุดเข้าไปทำงานจนจบ ทั้งที่ status เป็น 401
- ถ้า resolver โยน error บาง framework รุ่นเก่าไม่จับ promise ที่ reject ให้ request จะค้างจน timeout
ออก key
KEY="acme_prod_$(openssl rand -hex 32)"
printf '%s' "$KEY" | shasum -a 256 # เอา hash ไปใส่ SQL
echo "$KEY" # ส่งค่านี้ให้ consumer ครั้งเดียว
printf '%s' ไม่ใช่ echo — echo เติมขึ้นบรรทัดใหม่ให้ ทำให้ได้ hash คนละตัวกับที่โค้ดคำนวณ ผมเสียเวลาไปพักใหญ่กับเรื่องนี้ตอนที่ key เพิ่งออกมาสด ๆ แล้วใช้ไม่ได้โดยไม่มีเหตุผล
INSERT INTO client_api_key (consumer, key_hash, description)
VALUES ('billing-service', '<hash ที่ได้>', 'PLAT-1234 · ขอโดย …');
อย่ารวบด้วย SHA2('<key>', 256) ใน SQL แม้จะสั้นกว่า เพราะค่า key เต็มจะไปโผล่ใน log ของฐานข้อมูลหลายที่ (query log, general log, binlog) ซึ่งคุณอาจไม่ได้เป็นคนดูแล ส่วน shell history ติดอยู่ดีทั้งสองทาง เคลียร์หลังใช้
ตอนส่งให้อีกทีม ใส่ลง secret manager ของฝั่งเขาโดยตรงหรือส่งผ่านช่องที่หมดอายุได้ ไม่ใช่แชทหรืออีเมล — ปลายทางของค่านี้คือ config ของระบบ ไม่ใช่คน
ทำแยกทุก environment และถ้าอยากได้ audit ว่าใครออก key ไหนเมื่อไหร่ ให้เก็บ INSERT เป็น seed ใน repo แล้วให้ git history ทำหน้าที่นั้นแทน — ค่า hash ลง git ได้ ค่า key ห้าม
Revoke
UPDATE client_api_key SET revoked_at = NOW() WHERE id = ?;
มีผลทันที เพราะ resolver ค้น DB ใหม่ทุก request โดยไม่มี cache — เป็นจุดเดียวที่ดีไซน์เล็กชนะดีไซน์ใหญ่ วันที่ใส่ cache เข้าไป คุณกำลังเปลี่ยนสัญญาของคำว่า revoke จาก "ทันที" เป็น "ภายใน N วินาที" และต้องบอกทีมให้ชัดว่า N เท่ากับเท่าไร
"key นี้ยังมีคนใช้อยู่ไหม"
คำถามนี้มาก่อนการ revoke เสมอ ตอนเจอตารางที่มี key ค้างอยู่เจ็ดแถวและไม่มีใครกล้าลบ
ตารางไม่มี last_used_at โดยตั้งใจ เพราะมันแปลว่าต้อง UPDATE ทุก request ที่ผ่าน คือเปลี่ยน request ที่อ่านล้วนให้กลายเป็นการเขียน เพื่อข้อมูลที่ดูปีละครั้ง ตอบจาก log แทน เพราะ guard log ชื่อ consumer ทุก request ที่ผ่านอยู่แล้ว ถ้าใช้ Loki ก็ประมาณนี้ — เครื่องมืออื่นมีคำสั่งเทียบเคียง
count_over_time({app="my-service"} |= `api key accepted consumer=billing-service` [30d])
ข้อจำกัดคือคำตอบเชื่อได้ไม่เกิน retention ของ log ถ้าเป็น batch ที่รันเดือนละครั้งและ log เก็บ 14 วัน จะสรุปผิดว่าไม่มีใครใช้แล้ว เคสแบบนั้นให้ revoke แบบมีทางถอย คือ revoke แล้วรอ ถ้าไม่มีใครร้องค่อยลบ
สรุป
เก็บ hash ไม่เก็บ key, revoke เก็บเป็นเวลาแล้วกรองในตัว query, ไม่ใส่ UNIQUE บน consumer
Top comments (0)