BE 3 — API Key: กันไม่ให้ใครก็ได้เรียก endpoint ระหว่าง service
งานเล็กที่สุดในชุดนี้แต่พลาดกันมากที่สุด — สำรวจจริงพบว่า 10 จาก 11 service ต่อ mechanism ไว้แล้ว แต่มีเพียง 1 service ที่ประตูใช้งานได้จริง · 3 ชั้นที่ต้องครบ และกับดักที่ทำให้ 'ต่อแล้วแต่ไม่ทำงาน'
อัปเดต: 2026-08-23
ใช้กับ service เรียก service เท่านั้น · ห้ามใช้กับ browser — อะไรที่ browser ส่งได้ ผู้ใช้ก็อ่านได้ นั่นไม่ใช่ความลับอีกต่อไป
1. บทเรียนสำคัญที่สุดของหัวข้อนี้
จากการสำรวจจริงทั้ง 11 service ที่ deploy อยู่: 10 service เรียก AddApiKeyAuth(...) แล้ว แต่มีแค่ 1 service ที่ประตูทำงานได้จริง
สาเหตุคือมันมี 3 ชั้น ที่ต้องครบ และการทำครบ 1-2 ชั้นให้ความรู้สึกว่า “มีระบบป้องกันแล้ว” ทั้งที่ยังไม่มี:
| ชั้น | ทำอะไร | ถ้าขาดชั้นนี้ |
|---|---|---|
(ก) AddApiKeyAuth(...) ใน DI | ลงทะเบียนตัวตรวจ | ไม่มีอะไรตรวจเลย |
(ข) แขวน ApiKeyAuthFilter เข้า pipeline | ทำให้ตัวตรวจถูกเรียกจริง | ชั้น (ก) เป็นแค่ของประดับ — ไม่มี error ไม่มี log |
(ค) มี [RequireApiKey] บน endpoint และ มีคีย์ที่ตั้งค่าไว้จริง | ทำให้ประตูมีทั้งกลอนและกุญแจ | ถ้าไม่มี attribute = ประตูเปิด · ถ้ามี attribute แต่ ชุดคีย์ว่าง = ประตูปิดตาย ปฏิเสธทุกคน 100% |
เคสจริงที่เจอ: 2 service มีประตูที่ทำงานอยู่ แต่ปฏิเสธทุกคำขอ เพราะชุดคีย์ว่างเปล่าในทุกไฟล์ config ที่ตรวจได้ — และไม่มีใครรู้ เพราะไม่เคยมีใครเรียกสำเร็จมาก่อนอยู่แล้ว
2. ต่อสาย 3 ขั้น
// (ก) DI — อ่านจาก config แล้วสลับทิศ dictionary (ดู §3 กับดักที่ 1)
var cfg = configuration.GetSection(ApiKeyAuthConfig.SectionName).Get<ApiKeyAuthConfig>()
?? new ApiKeyAuthConfig();
services.AddApiKeyAuth(options =>
{
if (!string.IsNullOrEmpty(cfg.HeaderName))
options.HeaderName = cfg.HeaderName;
// config เก็บเป็น { ชื่อผู้เรียก : คีย์ } แต่ตัวตรวจต้องการ { คีย์ : ชื่อผู้เรียก } → สลับ
options.ApiKeys = cfg.ApiKeys
.Where(kvp => !string.IsNullOrEmpty(kvp.Value))
.ToDictionary(kvp => kvp.Value, kvp => kvp.Key);
});
// (ข) Program.cs — ใน AddControllers(...) แขวน filter ให้ทำงานจริง
options.Filters.Add<ApiKeyAuthFilter>();
// (ค) endpoint ที่ต้องการกุญแจ
[RequireApiKey]
[HttpPost("warm-cache")]
public async Task<IActionResult> WarmCache(...) { ... }
// ถ้าต้องยกเว้นบาง action ภายใต้ controller ที่ประกาศ [RequireApiKey] ไว้ทั้งชุด
[SkipApiKey]
[HttpGet("health")]
public IActionResult Health() => Ok();
filter จะข้ามการตรวจเมื่อ ไม่มี [RequireApiKey] ⇒ การแขวน filter แบบ global จึงปลอดภัย ไม่กระทบเส้นอื่น
3. กับดัก 3 ข้อที่เจอจริง
- ทิศของ dictionary — ⚠️ ไม่เหมือนกันทุก service ตรวจของเดิมก่อนใช้ snippet ข้างบน
- ทิศตามที่ library กำหนด คือ
{ คีย์ : ชื่อผู้เรียก }⇒ ชื่อ property คือตัวคีย์เอง (เป็นความลับ) - บาง service (รวมถึงตัวที่ snippet นี้คัดมา) กลับทิศ เป็น
{ ชื่อผู้เรียก : คีย์ }เพื่อให้อ่านง่าย แล้วสลับกลับตอนต่อสาย - ⇒ เปิดดู config เดิมของ service คุณและของใน KeyVault ก่อน ว่าใช้ทิศไหน แล้วเลือกว่าจะสลับหรือไม่สลับ · ใส่ snippet ผิดทิศ = ปฏิเสธทุกคำขอ โดยไม่มี error ให้เห็น
- ทิศตามที่ library กำหนด คือ
- ชื่อ header ไม่ตรงกัน — ฝั่งส่งกับฝั่งรับตั้งชื่อ header คนละชื่อ (default คือ
X-API-Key) เป็นสาเหตุจริงของ service หนึ่งที่ประตูเปิดไม่ได้เลย ยืนยันชื่อ header จากโค้ดทั้ง 2 ฝั่ง อย่าเชื่อเอกสาร [RequireApiKey]ที่ถูก comment ทิ้ง — พบในโค้ดจริง: endpoint ที่ตั้งใจให้ป้องกัน แต่ attribute ถูกปิดไว้ตอน debug แล้วไม่มีใครเปิดกลับ ให้ grep หา// [RequireApiKey]ด้วย
4. ทำไมต้องเทียบคีย์แบบ “เวลาเท่ากันเสมอ”
== ธรรมดาจะหยุดเทียบทันทีที่เจอตัวอักษรต่างตัวแรก ⇒ เวลาที่ใช้ตอบกลับ บอกใบ้ว่าคีย์ที่เดามาถูกไปกี่ตัว คนร้ายเดาทีละตัวอักษรได้ library จึงเทียบกับ ทุกคีย์ที่ตั้งไว้เสมอ และใช้ฟังก์ชันเทียบแบบเวลาคงที่:
var isValid = false;
foreach (var knownKey in _apiKeys.Keys)
{
// ไม่ break กลางคัน และไม่ใช้ || แบบ short-circuit — ตั้งใจให้เวลาเท่ากันทุกกรณี
isValid |= CryptographicOperations.FixedTimeEquals(candidateBytes, knownBytes);
}
ห้ามเขียนเทียบเอง — ใช้ของใน library
5. Config + ที่เก็บค่า
// appsettings.json — โครงเท่านั้น ห้ามใส่ค่าคีย์จริงลง repo
"ApiKeyAuth": {
"HeaderName": "X-API-Key",
"ApiKeys": {}
}
| key | เก็บที่ไหน | หมายเหตุ |
|---|---|---|
ApiKeyAuth:HeaderName | appsettings + IaC ทุก env | ต้องตรงกับฝั่งผู้เรียกทุกตัว |
ApiKeyAuth:ApiKeys:* | 🔴 KeyVault เท่านั้น (mount เป็น env var) | ค่าคีย์คือความลับ · ห้ามอยู่ใน repo หรือใน IaC เป็น plaintext ⚠️ ถ้า service ใช้ทิศตาม library ( {คีย์ : ชื่อผู้เรียก}) ชื่อ key ใน config ก็เป็นความลับด้วย — ห้ามเขียนลงเอกสาร/ticket/PR description |
🔴 กุญแจดอกเดียวที่หลายคนใช้ร่วมกัน = ถอนสิทธิ์ทีละคนไม่ได้ — พบเคสจริงที่ 3 service ใช้ secret ก้อนเดียวกันภายใต้ชื่อ config คนละชื่อ ผลคือฝั่งรับแยกไม่ออกว่าใครเรียกมา และการหมุนกุญแจ 1 ครั้งกระทบทั้ง 3 service พร้อมกัน ตั้งกุญแจแยกต่อผู้เรียกตั้งแต่แรก
ค่าที่มาจาก KeyVault จะ ทับ appsettings ทุกชั้น — ถ้าแก้ appsettings แล้ว “ไม่มีผล” ให้ไปดูที่ KeyVault ก่อนเป็นที่แรก
6. Acceptance test
[Theory]
[InlineData(null)] // ไม่ส่ง header
[InlineData("")] // ส่งแต่ว่าง
[InlineData("wrong-key-value")] // ส่งผิด
public async Task ProtectedEndpoint_WithoutValidKey_Returns401(string? key)
{
var response = await CallWithApiKey(key);
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task ProtectedEndpoint_WithValidKey_Succeeds() // เคสบวก — ต้องมีเสมอ
{
var response = await CallWithApiKey(TestKeys.Valid);
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
🔴 ต้องมีเคสบวกเสมอ — ชุดทดสอบที่มีแต่เคสลบจะเขียวสนิทแม้ประตูจะปิดตายปฏิเสธทุกคน ซึ่งเป็นอาการที่เจอจริงมาแล้ว 2 service
7. Checklist
- ครบทั้ง 3 ชั้น (ก)(ข)(ค) — ตรวจชั้น (ข) ด้วยตาว่า filter ถูกแขวนจริง
- สลับทิศ dictionary ตอนต่อสาย
- ชื่อ header ตรงกันทั้งฝั่งส่งและฝั่งรับ (ยืนยันจากโค้ด 2 ฝั่ง)
- grep หา
[RequireApiKey]ที่ถูก comment ทิ้ง - ค่าคีย์อยู่ใน KeyVault ไม่ใช่ repo ·
HeaderNameอยู่ใน IaC ครบทุก env - มี test ทั้งเคสบวกและเคสลบ