ASP.NET Core API Key 身份驗證:自訂 AuthenticationHandler 與 JWT 並存
ASP.NET Core API Key 身份驗證:自訂 AuthenticationHandler 與 JWT 並存
前言
許多前後分離專案的認證一直都是 JWT:後台管理員登入後拿到 Token,之後每個請求都帶 Authorization: Bearer ...。
用久了大家很容易有個錯覺——好像 ASP.NET Core 的認證就只有 JWT 這條路。
其實不是。ASP.NET Core 的認證系統是可插拔的 Scheme 架構,JWT 只是其中一種掛上去的 Scheme 而已。
這次有個需求剛好可以拿來示範:ERP 廠商要打我們的 API,但他們不是「會登入的使用者」,給不了帳密、也不適合走 JWT 那套換 Token 的流程。
這種「機器對機器、長期有效的靜態金鑰」場景,API Key 才是對的工具。
這篇想做兩件事:
- 框架不是只能 JWT,要在同一個專案裡同時跑好幾種認證方式是很自然的事。
- 把我自己寫的這版實作記下來。網路上 API Key 的文章不少,但很多不是繞太遠,就是沒講清楚「怎麼跟既有 JWT 並存而不互相打架」,這版我盡量寫得好懂一點。
先搞懂 Scheme 是什麼
關鍵概念只有一個:Scheme。
ASP.NET Core 把「一種認證方式」抽象成一個 Scheme,每個 Scheme 背後對應一個 Handler,負責解析請求、回答「這個人到底是誰、驗證過了沒」。
你可以在同一個應用程式裡同時掛好幾個 Scheme,彼此互不干擾:
| Scheme | 給誰用 | 憑證放哪 | 怎麼驗 |
|---|---|---|---|
| JWT(已有) | 後台管理員 | Authorization: Bearer ... | 驗簽章與到期時間 |
| API Key(新增) | ERP 廠商 | X-Api-Key: ... | 比對設定檔裡的合法清單 |
而我們要做的事,就是寫一個自己的 Handler,掛成一個叫 "ApiKey" 的新 Scheme,然後在需要的 Controller 上指定「這條路由走 ApiKey,不走 JWT」。
重點先講在前面:這不是「把 JWT 換掉」,而是「在 JWT 旁邊多加一個」。 現有端點完全不用動。
AuthenticationHandler<TOptions> 是框架提供的抽象基底類別,幫你把繁瑣的東西都包好了,
你真正要寫的只有一個方法——HandleAuthenticateAsync(),回傳成功或失敗而已。
請求是怎麼跑的
在動手前先把整條路徑想過一遍,後面 debug 會輕鬆很多:
HTTP Request Auth Middleware ApiKeyHandler 比對 appsettings 建立 Claims 進入 Controller
帶 X-Api-Key ─► 識別目標 Scheme ─► HandleAuthenticateAsync ─► ExternalApi:ApiKeys[] ─► ExternalApiClient ─► ✅只要 Header 缺少、或金鑰不在清單裡,Handler 就回 AuthenticateResult.Fail(),
ASP.NET Core 會自動回 401 Unauthorized,根本不會進到你的 Action。這點很重要——驗證失敗的處理不需要你寫,框架包好了。
實作步驟
1. 建立 Handler 類別
在 Authentication/ 資料夾建一個繼承 AuthenticationHandler<AuthenticationSchemeOptions> 的類別,覆寫 HandleAuthenticateAsync()。
整段邏輯就四步:確認 Header → 取出合法清單 → 比對 → 建立身份。
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text.Encodings.Web;
namespace EsignGateway.API.Admin.Authentication;
public class ApiKeyAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
// Header 名稱固定為 X-Api-Key
private const string ApiKeyHeaderName = "X-Api-Key";
public ApiKeyAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
// 1. 確認 Header 存在
if (!Request.Headers.TryGetValue(ApiKeyHeaderName, out var apiKeyValues))
return Task.FromResult(AuthenticateResult.Fail("缺少 X-Api-Key Header"));
// 2. 從 appsettings 取出合法金鑰清單
var providedKey = apiKeyValues.ToString();
var validKeys = Context.RequestServices
.GetRequiredService<IConfiguration>()
.GetSection("ExternalApi:ApiKeys")
.Get<string[]>() ?? [];
// 3. 比對
if (!validKeys.Contains(providedKey))
return Task.FromResult(AuthenticateResult.Fail("無效的 API Key"));
// 4. 認證成功:建立 Claims Identity
var claims = new[] { new Claim(ClaimTypes.Name, "ExternalApiClient") };
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
AuthenticateResult有三種狀態值得分清楚:Success(過了)、Fail(驗了但不對)、NoResult(這個 Scheme 不處理這個請求)。
這版我們只用前兩種就夠了——有帶 Header 但金鑰錯 →Fail;沒帶 Header 也直接Fail,因為外部端點本來就強制要金鑰。
2. 在 appsettings.json 設定金鑰清單
金鑰用字串陣列存,支援多組(不同廠商各持一組,將來要停用哪一組只要從清單移掉):
{
"ExternalApi": {
"ApiKeys": [
"your-erp-vendor-key-001",
"your-erp-vendor-key-002"
]
}
}正式環境別把金鑰明碼寫進 appsettings.json
這份是會進版控的。正式金鑰請改用 appsettings.Production.json(列入 .gitignore),
或乾脆透過部署平台的環境變數覆蓋——對應的環境變數寫法是 ExternalApi__ApiKeys__0=your-key(用雙底線分層、結尾數字是陣列索引)。
3. 在 DI 註冊這個 Scheme
這一步是整篇最容易踩雷的地方,請特別看清楚。
// JWT 認證(既有,這裡會設定「預設 Scheme」)
services.AddAuthenticationByJwtIdentity(configuration);
// API Key 認證(掛一個額外的 Scheme,不碰預設值)
services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
"ApiKey", // Scheme 名稱,等下 Controller 會用到這個字串
null // 不需要額外 options,傳 null
);注意:第二段的 AddAuthentication() 沒有帶參數,它只是回到既有的認證 builder 上鏈式接一個 .AddScheme()。
如果你手癢寫成 services.AddAuthentication("ApiKey")(帶了預設 Scheme 名稱),就會把 JWT 設好的預設 Scheme 蓋掉,
結果就是原本好好的後台端點突然全部開始要 API Key——這個雷我幫你踩過了。
4. 在 Controller 指定走 ApiKey
最後在外部端點的 Controller 加一行 [Authorize(AuthenticationSchemes = "ApiKey")],
框架就只會用 ApiKey Handler 驗這條路由,完全不碰 JWT:
// 指定只接受 ApiKey 驗證(不走 JWT)
[Authorize(AuthenticationSchemes = "ApiKey")]
[Route("api/external/contract-signing-records")]
[Tags("合約簽署紀錄(外部)")]
public class ContractSigningRecordExternalController : BaseApiController
{
// Action 完全不用改,認證由上面的 Attribute 決定
[HttpGet]
public async Task<IActionResult> GetAll() { ... }
}整個 Controller 都是外部端點,就把
[Authorize]放類別上;只有部分 Action 要走 API Key,標在個別方法上也行。
重點是AuthenticationSchemes的字串要跟步驟 3 註冊的名稱一模一樣。
設定參數一覽
把分散在各檔案的設定整理在一起,將來交接時看這張表就懂:
| 參數 | 位置 | 說明 |
|---|---|---|
X-Api-Key | Request Header | 呼叫端必帶,值為靜態金鑰。名稱固定,要改得改程式碼裡的常數 |
ExternalApi:ApiKeys | appsettings.json | 字串陣列,所有合法金鑰。用 .Contains() 精確比對,大小寫有別 |
"ApiKey"(Scheme 名稱) | DI 註冊 / [Authorize] | 兩處必須一致,改一邊就要改另一邊 |
ClaimTypes.Name = "ExternalApiClient" | Handler 內 | 認證成功後的身份名稱。將來若要依廠商做細部權限,可改傳更精確的識別值 |
測試
用 curl 直接打三種情境就能驗收:
# ✅ 情境 1:帶正確金鑰 → 200 OK
curl -H "X-Api-Key: your-erp-vendor-key-001" \
https://localhost:7001/api/external/contract-signing-records
# ❌ 情境 2:帶錯誤金鑰 → 401 Unauthorized
curl -H "X-Api-Key: wrong-key" \
https://localhost:7001/api/external/contract-signing-records
# ❌ 情境 3:完全不帶 Header → 401 Unauthorized
curl https://localhost:7001/api/external/contract-signing-records如果用 Scalar(內建的 OpenAPI UI),到 Authorization 區塊選 ApiKey Scheme,輸入金鑰就能直接對外部端點發請求,不用手動拼 curl。
踩雷紀錄
整理幾個我實際遇到的狀況:
- 永遠 401,但金鑰明明沒錯 — 先檢查
Program.cs有沒有呼叫app.UseAuthentication(),而且要在app.UseAuthorization()之前。順序錯了認證等於沒掛。 - JWT 端點突然也開始要 API Key — 就是步驟 3 講的雷:在 JWT 設好預設 Scheme 之後,又「重新呼叫」了帶參數的
AddAuthentication(...)把它蓋掉。改成在同一個 builder 上鏈式接.AddScheme()就好。 - 本機能過、測試環境全 401 — 金鑰是跟著環境走的,記得在各環境的設定或環境變數補上金鑰,別只設了本機那份。
- Handler 每次請求都重讀設定,會不會很慢? — 不會,
IConfiguration本身有快取。而且這反而是好處:若設定檔開了reloadOnChange: true,新增/停用金鑰不用重啟服務就生效。
小結
| JWT | API Key | |
|---|---|---|
| 適合對象 | 會登入的人(後台管理員) | 機器對機器(ERP 廠商) |
| 憑證型態 | 短期、會過期、需換發 | 長期、靜態 |
| 在框架裡的角色 | 一個 Scheme | 另一個 Scheme |
| 兩者關係 | 並存,互不干擾 |
這篇文章要表達認證方式是「掛上去的 Scheme」,不是「二選一的選擇題」。
JWT、API Key,甚至之後要加 mTLS、HMAC 簽章……都是同一套 AuthenticationHandler 機制的不同實作,
搞懂 Scheme 這個抽象,要加幾種就加幾種。