ASP.NET

ASP.NET Core で JWT 認証を実装する

ASP.NET Core で API に JWT 認証を入れる手順です。プロジェクト作成から、保護したエンドポイントに実際にトークンを通すところまで書きます。

ASP.NET JWT Authentication

必要なのは .NET 8 SDK 以降と、C# と REST API がひととおり分かっていることだけです。

プロジェクトを作る

dotnet new webapi -n JwtAuthDemo --use-controllers cd JwtAuthDemo dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer dotnet add package System.IdentityModel.Tokens.Jwt

--use-controllers を忘れないでください。.NET 8 から dotnet new webapi は Minimal API のテンプレートを吐くようになったので、これを付けないと Controllers フォルダが生成されず、この先の手順と噛み合わなくなります。

設定を書く

appsettings.json に JWT 用のセクションを足します。

{ "Jwt": { "Key": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!", "Issuer": "https://yourdomain.com", "Audience": "https://yourdomain.com", "ExpiryMinutes": 60 }, "Logging": { "LogLevel": { "Default": "Information" } } }

Key は署名に使う秘密鍵です。HMAC-SHA256 で署名する以上、鍵は 256 ビット、つまり 32 バイト以上必要で、短いと実行時に例外になります。ASCII なら 32 文字以上と考えておけば足ります。当然ながらリポジトリに入れる値ではありません(後述します)。

Issuer は発行者、Audience は想定する受け取り手です。どちらも検証時に一致が要求されるので、発行側と検証側で同じ値を使います。

モデル

Models フォルダを作って2つ置きます。

// LoginRequest.cs namespace JwtAuthDemo.Models; public class LoginRequest { public string Username { get; set; } = string.Empty; public string Password { get; set; } = string.Empty; }
// LoginResponse.cs namespace JwtAuthDemo.Models; public class LoginResponse { public string Token { get; set; } = string.Empty; public DateTime Expiration { get; set; } }

トークンを発行するサービス

Services/JwtService.cs を作ります。

using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.IdentityModel.Tokens; namespace JwtAuthDemo.Services; public class JwtService { private readonly IConfiguration _configuration; public JwtService(IConfiguration configuration) { _configuration = configuration; } public string GenerateToken(string username, string role) { var claims = new[] { new Claim(ClaimTypes.Name, username), new Claim(ClaimTypes.Role, role), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) }; var key = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(_configuration["Jwt:Key"]!) ); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var expiry = DateTime.UtcNow.AddMinutes( Convert.ToDouble(_configuration["Jwt:ExpiryMinutes"]) ); var token = new JwtSecurityToken( issuer: _configuration["Jwt:Issuer"], audience: _configuration["Jwt:Audience"], claims: claims, expires: expiry, signingCredentials: credentials ); return new JwtSecurityTokenHandler().WriteToken(token); } }

やっていることは、クレーム(トークンに載せるユーザー情報)を組み立て、秘密鍵で署名して、文字列にするだけです。

Jti はトークンごとに一意な ID です。今の実装では使いませんが、後からトークンを個別に失効させたくなったときに必要になるので、最初から入れておくほうが楽です。

ここに載せた値は Base64 でエンコードされているだけで、暗号化はされていません。誰でもデコードして中身を読めます。署名が守るのは「改ざんされていないこと」であって「見られないこと」ではないので、パスワードや個人情報をクレームに入れてはいけません。

Program.cs

using System.Text; using JwtAuthDemo.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddSingleton<JwtService>(); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = builder.Configuration["Jwt:Issuer"], ValidAudience = builder.Configuration["Jwt:Audience"], IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!) ) }; }); builder.Services.AddAuthorization(); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run();

UseAuthentication()UseAuthorization() より先に呼ぶ必要があります。認証が「誰か」を確定させ、認可がその結果を見て「通すか」を決めるので、逆順だと認可の時点で身元が未確定になり、常に 401 が返ります。ここは順序を入れ替えてもコンパイルは通るので、気づきにくい部類のミスです。

ログインとエンドポイント

// Controllers/AuthController.cs using JwtAuthDemo.Models; using JwtAuthDemo.Services; using Microsoft.AspNetCore.Mvc; namespace JwtAuthDemo.Controllers; [ApiController] [Route("api/[controller]")] public class AuthController : ControllerBase { private readonly JwtService _jwtService; public AuthController(JwtService jwtService) { _jwtService = jwtService; } [HttpPost("login")] public IActionResult Login([FromBody] LoginRequest request) { // 動作確認用。実際はデータベースのハッシュと照合する if (request.Username == "admin" && request.Password == "password123") { var token = _jwtService.GenerateToken(request.Username, "Admin"); return Ok(new LoginResponse { Token = token, Expiration = DateTime.UtcNow.AddMinutes(60) }); } return Unauthorized(new { message = "認証情報が無効です" }); } }

認証情報の直書きは動作確認のためのものです。実際には ASP.NET Core Identity なり自前のユーザーテーブルなりに対して、ハッシュ化されたパスワードと照合します。

保護対象のエンドポイントはこうなります。

// Controllers/WeatherController.cs using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace JwtAuthDemo.Controllers; [ApiController] [Route("api/[controller]")] public class WeatherController : ControllerBase { [HttpGet("public")] public IActionResult GetPublicData() { return Ok(new { message = "これは公開データです" }); } [Authorize] [HttpGet("protected")] public IActionResult GetProtectedData() { return Ok(new { message = "これは保護されたデータです", user = User.Identity?.Name }); } [Authorize(Roles = "Admin")] [HttpGet("admin")] public IActionResult GetAdminData() { return Ok(new { message = "管理者専用データ" }); } }

動かす

dotnet run

ポートは Properties/launchSettings.json に書かれている値が使われます。以下では 7000 番としますが、自分の環境の値に読み替えてください。

トークンを取ります。

curl -X POST https://localhost:7000/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"password123"}'
{ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expiration": "2026-01-23T11:00:00Z" }

そのトークンを付けて保護されたほうを叩きます。

curl -X GET https://localhost:7000/api/weather/protected \ -H "Authorization: Bearer YOUR_TOKEN_HERE"
{ "message": "これは保護されたデータです", "user": "admin" }

つまずきやすいところ

401 が返る。 まず Authorization ヘッダーの形式を疑ってください。Bearer と トークンの間はスペース1つで、Bearer は必須です。ヘッダー自体が付いていないケースも多いので、-v を付けて実際に送られているかを先に確認します。

鍵を変えたのに古いトークンが通る/通らない。 署名に使った鍵と検証に使う鍵が一致していなければ検証は失敗します。appsettings.json を書き換えたらアプリを再起動してください。

有効期限が切れているのに通る。 TokenValidationParameters.ClockSkew の既定値が5分あるためです。サーバー間の時刻ずれを吸収するための猶予で、意図的な仕様です。テストで厳密に切りたいときは ClockSkew = TimeSpan.Zero を設定します。

User.Identity.Name が null になる。 JWT の標準クレーム名(sub など)と .NET の ClaimTypes は別物で、JwtSecurityTokenHandler が既定でマッピングしています。上のコードのように ClaimTypes.Name で発行していれば取れますが、sub で発行して User.Identity.Name を期待すると噛み合いません。ここは一度必ず引っかかります。

本番に持っていく前に

秘密鍵を appsettings.json に置いたままにしないこと。これが一番大事です。環境変数、Azure Key Vault、開発中なら dotnet user-secrets を使います。リポジトリに一度コミットした鍵は、履歴から消しても漏れたものとして扱ってください。

そのうえで、HTTPS でしか運ばないこと、有効期限を短め(15〜60分)にすること、そして短い有効期限を成立させるためにリフレッシュトークンを用意すること。この3つはセットです。有効期限だけ短くすると、ユーザーが数十分おきにログインし直すことになります。