API設計とJWT exp・OAuth2有効期限
公開: 2026-06-16・対象: バックエンド・API設計者
JWT・OAuth2・Webhook署名など、APIの実装でUNIXタイムスタンプを扱う場面は多いですが、秒/ミリ秒の取り違え、クライアントとサーバーの時刻ズレ(clock skew)、リプレイ攻撃の許容窓など、定石を外すと脆弱性に直結します。標準仕様で『どこに何が規定されているか』を整理しました。
RFC 7519 JWT の時刻クレーム(exp / nbf / iat)
IETF RFC 7519 で規定されているJSON Web Tokenの時刻関連クレームは3つです。すべて『NumericDate』型、つまりUNIX秒。ミリ秒ではありません。
| クレーム | 意味 | 必須? | 典型値 |
|---|---|---|---|
| exp | 有効期限。これ以降は拒否 | RFC上は任意 | iat + 3600(1時間) |
| nbf | Not Before。これ以前は拒否 | 任意 | iat(即時有効) |
| iat | 発行時刻(Issued At) | 任意 | Math.floor(Date.now()/1000) |
// 1時間有効なJWTを発行
const now = Math.floor(Date.now() / 1000)
const payload = {
sub: "user_12345",
iat: now,
nbf: now,
exp: now + 60 * 60, // 1時間 = 3600秒
}
// 検証側
if (payload.exp < Math.floor(Date.now()/1000)) {
throw new Error("Token expired")
}1000倍忘れの逆ミスに注意。Date.now()をそのままexpに入れると『西暦57000年まで有効』なトークンになり、永久に失効しません。必ず/1000を入れます。
時刻ズレ許容(clock skew)の設計
クライアントとサーバーで時刻が完全に一致することは現実にはありません。NTP同期されていても秒オーダーのズレは発生します。RFC 7519は『発行者と検証者の時刻ズレに対応するため、若干の余裕(leeway)を設けるべき』と明記しています。
// 一般的な leeway 設計(60〜300秒)
const LEEWAY_SECONDS = 60
const now = Math.floor(Date.now() / 1000)
// nbf: leeway分早くなっても許容
if (payload.nbf > now + LEEWAY_SECONDS) reject("not yet valid")
// exp: leeway分過ぎても許容
if (payload.exp < now - LEEWAY_SECONDS) reject("expired")典型値は60秒〜300秒(5分)。長すぎると無効化したいトークンが残り続け、短すぎるとモバイル端末の時刻ズレで失敗します。多くのライブラリは初期値60秒(Auth0、Microsoft Identity Platform)または300秒(AWS Cognito)。
OAuth2の expires_in(秒)と expires_at(UNIX秒)
OAuth2のtoken応答に含まれるexpires_inは『発行からの秒数』であり、絶対時刻ではありません(RFC 6749)。これをクライアント側でUNIX秒の絶対時刻に変換するのが定石です。
// OAuth2のtoken応答
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600, // 発行から3600秒(相対)
"refresh_token": "..."
}
// クライアント側で絶対時刻に変換して保存
const expiresAt = Math.floor(Date.now()/1000) + tokenResponse.expires_in
localStorage.setItem("token_expires_at", String(expiresAt))
// 使用直前のチェック(5分前にリフレッシュ開始)
const REFRESH_THRESHOLD = 5 * 60
if (expiresAt - Math.floor(Date.now()/1000) < REFRESH_THRESHOLD) {
await refreshToken()
}Webhook署名のtimestamp(Stripe・GitHub・Slack)
Webhook検証では『送信時刻+ペイロード』のHMAC署名と、サーバー側のリプレイ攻撃防止のための時刻ウィンドウチェックがセットになります。各社の実装例:
- Stripe:
Stripe-Signatureヘッダにt=1734220800,v1=abc...。tはUNIX秒。署名対象はt.payload。許容窓は推奨5分。 - GitHub Webhook:
X-Hub-Signature-256はHMAC-SHA256のみで時刻は含まれない。リプレイ防止はdelivery ID(X-GitHub-Delivery)の冪等性管理で実装。 - Slack:
X-Slack-Request-TimestampはUNIX秒。署名対象はv0:ts:body。許容窓は5分を推奨。 - AWS SigV4:
X-Amz-DateはISO 8601基本形式(20241215T000000Z)。X-Amz-Expiresは秒数。許容窓は15分。
// Slackスタイルのwebhook署名検証
import { createHmac, timingSafeEqual } from "crypto"
function verifyWebhook(req, secret) {
const ts = parseInt(req.headers["x-slack-request-timestamp"], 10)
const now = Math.floor(Date.now() / 1000)
// リプレイ攻撃防止: 5分以内のリクエストのみ受け付け
if (Math.abs(now - ts) > 300) {
throw new Error("Replay attack: timestamp too old")
}
const body = req.rawBody
const baseString = `v0:${ts}:${body}`
const expected = "v0=" + createHmac("sha256", secret)
.update(baseString).digest("hex")
const received = req.headers["x-slack-signature"]
if (!timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
throw new Error("Invalid signature")
}
}署名URL(presigned URL)の有効期限
S3・GCSなどの署名URLは、URL自体にUNIX秒の有効期限を埋め込みます。
# AWS S3 Presigned URL(SigV4) https://bucket.s3.amazonaws.com/key ?X-Amz-Algorithm=AWS4-HMAC-SHA256 &X-Amz-Date=20241215T000000Z &X-Amz-Expires=3600 # 発行から3600秒間有効 &X-Amz-SignedHeaders=host &X-Amz-Signature=... # GCS Signed URL(V4) https://storage.googleapis.com/bucket/key ?X-Goog-Algorithm=GOOG4-RSA-SHA256 &X-Goog-Date=20241215T000000Z &X-Goog-Expires=3600 &X-Goog-SignedHeaders=host &X-Goog-Signature=...
有効期限が短すぎる(例:1分)と、ユーザーがダウンロードボタンを押す前に切れる事故が起きます。長すぎる(例:7日)と、URL流出時の被害が大きくなります。多くのユースケースで15分〜1時間が推奨。
補足
- ・本記事は2026-06-16時点のRFC 7519(JWT)・RFC 6749(OAuth2)および各社公式ドキュメントに基づきます。
- ・実装の最終チェックは各社の最新Webhook仕様(Stripe・GitHub・Slack・AWS)でご確認ください。
- ・HMAC比較は必ず
timingSafeEqual等の定時間比較関数を使い、タイミング攻撃を防ぎます。 - ・トークン署名にはRS256(公開鍵検証)またはHS256(共通鍵)が一般的。実装側でアルゴリズム指定を硬く(alg=noneを拒否)してください。
JWT・署名URLの有効期限を実機で確認する
発行したexpクレームの値を貼り付けて、何時何分まで有効か即時で確認できます:UNIXタイムスタンプ変換