「1ライセンスにつき同時に1台まで」を JWT で実現する話です。ライセンス管理が必要なアプリでは定番の要件ですが、素直に作ると穴が空きやすいところでもあります。

先に、この仕組みで守れる範囲
作り始める前にはっきりさせておくべきことがあります。デバイス識別子はクライアントが送ってくる値なので、偽装しようと思えばできます。
したがってこれは、アカウントの使い回しを防ぐライセンス管理の仕組みであって、攻撃者に対するセキュリティ境界ではありません。「同僚とパスワードを共有して2人で使う」は止められますが、「DevTools を開いて送信値を書き換える気がある人間」は止まりません。
そこを理解したうえで、止めたいのが前者なら十分に機能します。
デバイスフィンガープリント
ブラウザから取れる情報を並べてハッシュにします。
// deviceFingerprint.ts interface DeviceInfo { userAgent: string; screenResolution: string; timezone: string; language: string; } export async function generateDeviceFingerprint(): Promise<string> { const deviceInfo: DeviceInfo = { userAgent: navigator.userAgent, screenResolution: `${screen.width}x${screen.height}`, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, language: navigator.language }; const fingerprintString = Object.values(deviceInfo).join('|'); const bytes = new TextEncoder().encode(fingerprintString); const digest = await crypto.subtle.digest('SHA-256', bytes); return Array.from(new Uint8Array(digest)) .map(b => b.toString(16).padStart(2, '0')) .join(''); }
ハッシュ化に Node の crypto モジュールを使っているサンプルをよく見かけますが、ブラウザでは動きません。Web Crypto API の crypto.subtle.digest を使います。非同期になるので、呼び出し側も await が要ります。
navigator.platform は非推奨なので入れていません。解像度も外部ディスプレイの抜き差しで変わるため、識別要素としては弱いほうです。要素を増やすほど一意性は上がりますが、同時に「同じ端末なのに値が変わる」頻度も上がります。
テーブル
CREATE TABLE ActiveTokens ( Id INT PRIMARY KEY IDENTITY(1,1), UserId INT NOT NULL, DeviceFingerprint NVARCHAR(64) NOT NULL, TokenJti NVARCHAR(36) NOT NULL, IssuedAt DATETIME2 NOT NULL, ExpiresAt DATETIME2 NOT NULL, LastActivity DATETIME2 NOT NULL, DeviceInfo NVARCHAR(MAX), CONSTRAINT FK_ActiveTokens_Users FOREIGN KEY (UserId) REFERENCES Users(Id), CONSTRAINT UQ_ActiveTokens_UserId UNIQUE (UserId), INDEX IX_TokenJti (TokenJti) );
UserId に一意制約を張っているのが要点です。「1ユーザーにつきアクティブトークンは1行まで」をデータベース側で保証しておかないと、後述の競合で2台同時に通ります。
TokenJti は JWT の jti クレームと対応し、検索の主役になるのでインデックスを張ります。
ログイン時の判定
public async Task<LoginResult> LoginAsync( string username, string password, string deviceFingerprint) { var user = await AuthenticateUser(username, password); if (user == null) return LoginResult.Failed("Invalid credentials"); var existingToken = await _context.ActiveTokens .FirstOrDefaultAsync(t => t.UserId == user.Id && t.ExpiresAt > DateTime.UtcNow); if (existingToken != null) { if (existingToken.DeviceFingerprint != deviceFingerprint) { return LoginResult.Failed( "This account is already in use on another device. " + "Please log out from the other device first." ); } // 同じ端末からの再ログイン。既存トークンを使い続ける existingToken.LastActivity = DateTime.UtcNow; await _context.SaveChangesAsync(); return LoginResult.Success(existingToken.TokenJti); } var jti = Guid.NewGuid().ToString(); var token = _jwtService.GenerateToken(user, jti); _context.ActiveTokens.Add(new ActiveToken { UserId = user.Id, DeviceFingerprint = deviceFingerprint, TokenJti = jti, IssuedAt = DateTime.UtcNow, ExpiresAt = DateTime.UtcNow.AddHours(8), LastActivity = DateTime.UtcNow, DeviceInfo = GetDeviceInfoJson(deviceFingerprint) }); await _context.SaveChangesAsync(); return LoginResult.Success(token); }
期限切れの行が残り続けるので、ExpiresAt を過ぎたものを消すバッチは別途必要です。
競合に注意
このコードは「読んでから書く」形なので、2台がほぼ同時にログインすると、どちらも existingToken == null を見てから両方が INSERT に進みます。テーブルの一意制約はこれを弾くためのもので、後発の SaveChangesAsync が DbUpdateException になります。
つまり例外を握りつぶさず、「他の端末で使用中」として扱う必要があります。制約なしで運用すると、狙って同時に叩くだけで制限を抜けられます。
リクエストごとの検証
public async Task InvokeAsync(HttpContext context, AppDbContext dbContext) { var token = ExtractTokenFromHeader(context); if (token == null) { context.Response.StatusCode = 401; return; } var handler = new JwtSecurityTokenHandler(); ClaimsPrincipal principal; try { // 署名と有効期限をここで検証する principal = handler.ValidateToken(token, _validationParameters, out _); } catch (SecurityTokenException) { context.Response.StatusCode = 401; return; } var jti = principal.FindFirst(JwtRegisteredClaimNames.Jti)?.Value; if (jti == null) { context.Response.StatusCode = 401; return; } var activeToken = await dbContext.ActiveTokens .FirstOrDefaultAsync(t => t.TokenJti == jti && t.ExpiresAt > DateTime.UtcNow); if (activeToken == null) { context.Response.StatusCode = 401; await context.Response.WriteAsJsonAsync(new { error = "Token has been revoked or expired" }); return; } await _next(context); }
ここが一番間違えやすいところです。JwtSecurityTokenHandler.ReadJwtToken() はトークンを解析するだけで署名を検証しません。これで済ませてしまうと、攻撃者は任意の内容の JWT を自作して送れます。DB に存在する jti を当てる必要はありますが、そもそも署名の意味がなくなります。必ず ValidateToken() を使ってください。
もうひとつ。元の実装ではリクエストのたびに LastActivity を更新していましたが、これは全 GET が書き込みを伴うということです。トラフィックが増えると効いてくるので、更新は間引くべきです(前回から5分以上経っていたら書く、程度)。
強制ログアウト
public async Task<bool> ForceLogoutOtherDevicesAsync(int userId, string deviceFingerprint) { var tokensToRemove = await _context.ActiveTokens .Where(t => t.UserId == userId && t.DeviceFingerprint != deviceFingerprint) .ToListAsync(); if (tokensToRemove.Count == 0) return false; _context.ActiveTokens.RemoveRange(tokensToRemove); await _context.SaveChangesAsync(); return true; }
「他の端末からログアウトして続行」をユーザー自身に選ばせる導線は用意しておく必要があります。これがないと、前の端末を正しくログアウトせずに閉じたユーザーが、トークンの期限が切れるまで締め出されます。サポートに来る問い合わせの大半はこれです。
クライアント側
export async function login(username: string, password: string) { const deviceFingerprint = await generateDeviceFingerprint(); const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, deviceFingerprint }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.message); } const { token } = await response.json(); sessionStorage.setItem('jwt_token', token); return token; }
保存先を localStorage にしているサンプルをよく見ますが、XSS を踏んだ時点でトークンをそのまま読み出されます。要件が許すなら HttpOnly の Cookie が安全で、次善が sessionStorage です。どちらにしても、XSS がある状態では守れないという前提は変わりません。
401 が返ったらトークンを捨ててログイン画面に戻す処理は、API クライアント側に一箇所だけ置きます。
キャッシュを入れるとき
毎リクエストで DB を引くのが重いなら Redis を挟むことになりますが、素直に入れると強制ログアウトが効かなくなります。
「有効」という結果を5分キャッシュすると、他端末から蹴られたトークンが最大5分間そのまま通ります。取れる手は次のどれかです。
- キャッシュするのは「無効」の結果だけにする
- ログアウト時にキャッシュを明示的に削除する
- TTL を許容できる遅延(数十秒)まで縮める
即時性がどこまで要るかは要件次第です。ライセンス管理が目的なら数十秒の遅延はたいてい許容されます。
運用で決めておくこと
端末を買い替えたユーザー。 明示的なログアウトを踏んでいないことが多いので、メール認証か 2FA を挟んで解除できる経路を先に用意しておきます。
フィンガープリントが勝手に変わるケース。 ブラウザの更新で User-Agent は変わりますし、ディスプレイを繋げば解像度も変わります。完全一致で運用すると誤検知が出るので、いくつかの要素の一致で通す、あるいは変化を一定回数まで許容する作りにします。
モバイルアプリ。 ブラウザのフィンガープリントより、iOS の identifierForVendor や Android の ANDROID_ID のほうが安定します。ただしどちらも再インストールや設定で変わりうるので、不変の ID として扱うことはできません。
制限を厳しくするほど正規ユーザーが締め出される確率も上がります。どこで妥協するかはビジネス側の判断で、技術的にはどちらにも寄せられます。