手軽屋
ツール一覧

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時間)
nbfNot 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署名と、サーバー側のリプレイ攻撃防止のための時刻ウィンドウチェックがセットになります。各社の実装例:

// 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時間が推奨。

補足

JWT・署名URLの有効期限を実機で確認する

発行したexpクレームの値を貼り付けて、何時何分まで有効か即時で確認できます:UNIXタイムスタンプ変換