ブログへ戻る

HonoのAPIでSupabase JWTを検証する——4つの方法を比較する

HonoのAPIでSupabaseのJWTを検証する方法は4つある。@supabase/server、getClaims()、JWKミドルウェア、レガシーのJWT Secretを、署名方式・ネットワーク呼び出し・採用場面ごとに比較して解説する。

フロントエンドでSupabase Authを使い、ユーザーがログインするとアクセストークンが取得できる。そのトークンをHono APIにBearerトークンとして送り、APIがデータを返す前に「このトークンは本物か」を確認する処理が必要になる。

検証の手順自体はシンプルに見える。Authorizationヘッダーを取り出してJWTを確認すれば終わりだ。しかし、その下にはいくつかの判断が隠れている。どの署名鍵を使うのか。リクエストごとにネットワーク呼び出しが発生するのか。検証済みのID情報はSupabaseのRow Level Security(RLS)ポリシーにどう引き継ぐのか。そして2026年5月にパブリックベータとして公開された@supabase/serverは何を自動的に処理してくれるのか。

2026年8月時点で、Hono APIでSupabase JWTを検証する方法は4種類ある。それぞれ、対応する署名アルゴリズム、ネットワーク呼び出しの回数、自動化の範囲が異なる。この記事では各方法の仕組みと採用場面を説明し、そのままコピーして使えるTypeScriptのミドルウェアコードを示す。

動作確認:Node.js 22・AWS Lambda (nodejs22.x)、2026年8月。Hono 4.7以上、@supabase/supabase-js 2.50以上。@supabase/serverはパブリックベータ中のため、インストール前にnpmで現在のバージョンを確認すること。

どの検証方法を選ぶべきか

| 方法 | HS256 | RS256 / ES256 | ネットワーク呼び出し | 採用場面 | |---|---|---|---|---| | @supabase/server | ✓ | ✓ | 鍵方式による | 新規Hono API(パブリックベータ) | | supabase.auth.getClaims() | フォールバック¹ | ✓(JWKS、キャッシュ) | 鍵ローテーション時のみ | 既存APIの多くに適合 | | Hono jwkミドルウェア | ✗ | ✓(JWKS、キャッシュ) | 鍵ローテーション時のみ | 細かい制御が必要な場合 | | Hono jwtミドルウェア | ✓ | ✗ | なし | HS256レガシープロジェクト限定 |

¹ プロジェクトが対称鍵(HS256)を使っている場合、getClaims()getUser()と同等のサーバーサイド呼び出しにフォールバックする——リクエストごとに1回のAuthサーバーへのネットワーク呼び出しが発生する。JWKSのパフォーマンス上の優位性は、非対称鍵が設定されている場合にのみ得られる。

使える方法はプロジェクトの署名アルゴリズムで決まる。 Supabaseダッシュボードの Project Settings → API → JWT Settings で確認できる。非対称鍵のデフォルト化が行われる以前に作成されたプロジェクトはHS256を使っていることが多い。新しいプロジェクトはES256またはRS256をデフォルトとする場合がある。

新規のHono APIに対する推奨は、パブリックベータのリスクを許容できるなら@supabase/server、安定した選択肢が必要ならgetClaims()だ。getClaims()はプロジェクトの鍵方式にかかわらず正しく動作する。

前提条件

  • Hono 4.7以上
  • @supabase/supabase-js 2.50以上
  • TypeScript(strictモード)
  • 環境変数:
    • SUPABASE_URL — プロジェクトURL(https://<project-id>.supabase.co
    • SUPABASE_ANON_KEY — プロジェクトのanon公開鍵
    • SUPABASE_JWT_SECRET — レガシーのjwtミドルウェアを使う場合のみ必要

フロントエンドからトークンを送る

クライアント側では、現在のセッションを取得してAPIリクエストのAuthorizationヘッダーにアクセストークンを含める。

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)

async function fetchProfile() {
  const { data: { session } } = await supabase.auth.getSession()
  if (!session) throw new Error('未ログイン')

  const response = await fetch('https://your-api.example.com/api/profile', {
    headers: {
      'Authorization': `Bearer ${session.access_token}`,
    },
  })
  return response.json()
}

ここでgetSession()を使うのは適切だ。ブラウザのコンテキストでトークンを取り出して自分のAPIに送っているだけであり、サーバー側でユーザーを認証しているわけではない。

方法1:@supabase/server(パブリックベータ)

@supabase/serverはHonoアダプターを提供し、JWT検証・iss検証・Supabaseクライアントの生成・リクエストコンテキストの設定を自動的に処理する。最もハイレベルな選択肢だ。詳細は公式アナウンスを参照してほしい。

npm install @supabase/server@latest
import { Hono } from 'hono'
import { withSupabase } from '@supabase/server/adapters/hono'

const app = new Hono()

app.use('*', withSupabase())

app.get('/api/profile', (c) => {
  const { userClaims, supabase } = c.var.supabaseContext

  if (!userClaims) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  return c.json({
    userId: userClaims.sub,
    email: userClaims.email,
    role: userClaims.role,
  })
})

export default app

c.var.supabaseContextに含まれるもの:

  • supabase — リクエストのJWTを使うユーザースコープのクライアント。RLSポリシーが自動的に適用される
  • supabaseAdmin — サービスロールキーを使う管理者クライアント
  • userClaims — 検証済みのJWTクレーム。未認証リクエストの場合はnull
  • jwtClaimsissaud、カスタムクレームを含む生のJWTクレーム
  • authMode — 現在の認証モード

ユーザースコープのデータベースクエリにはsupabaseを使う。このクライアントはJWTをPostgresに転送するため、RLSポリシーが自動的に適用される。

注意: @supabase/serverは2026年8月時点でパブリックベータ中だ。c.var.supabaseContextやフィールド名などのアダプターAPIは、一般提供(GA)前に変更される可能性がある。アップグレード前に公式ドキュメントを確認すること。

方法2:getClaims()ミドルウェア

getClaims()はSupabaseのJWKSエンドポイント/.well-known/jwks.json)に対してJWTを検証する。JWKSはキャッシュされているため、最初のリクエスト以降は公開鍵をローカルで使った署名検証となり、リクエストごとのネットワーク呼び出しは発生しない。

import { Hono, type Context, type Next } from 'hono'
import { createClient } from '@supabase/supabase-js'

type JwtClaims = {
  sub: string
  email: string
  role: string
  exp: number
  iss: string
}

type Variables = {
  jwtClaims: JwtClaims
  token: string
}

const app = new Hono<{ Variables: Variables }>()

// JWT検証専用の共有クライアント——ユーザーごとの状態は持たない
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_ANON_KEY!
)

const EXPECTED_ISS = `${process.env.SUPABASE_URL}/auth/v1`

async function requireAuth(c: Context<{ Variables: Variables }>, next: Next) {
  const authorization = c.req.header('Authorization')
  if (!authorization?.startsWith('Bearer ')) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const token = authorization.slice(7)
  const { data, error } = await supabase.auth.getClaims(token)

  if (error || !data?.claims) {
    console.error('JWT verification failed:', error?.message)
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const claims = data.claims as JwtClaims

  // 別のSupabaseプロジェクトが発行したJWTを拒否する
  if (claims.iss !== EXPECTED_ISS) {
    console.error('JWT iss mismatch:', claims.iss)
    return c.json({ error: 'Unauthorized' }, 401)
  }

  c.set('jwtClaims', claims)
  c.set('token', token)
  await next()
}

app.use('/api/*', requireAuth)

app.get('/api/profile', (c) => {
  const claims = c.get('jwtClaims')
  return c.json({
    userId: claims.sub,
    email: claims.email,
  })
})

export default app

なぜgetUser()ではなくgetClaims()を使うのか。 getUser()はリクエストのたびにSupabase Authサーバーへのネットワーク呼び出しを行う。一方getClaims()はキャッシュ済みのJWKS公開鍵を使ってローカルで署名を検証し、トークン自体からクレームを読み取る。リクエスト量が多いAPIでは、この違いが積み重なる。getUser()が必要な場面は、セッションが明示的に失効していないかなど、Authサーバーから権威あるユーザー情報を確認しなければならない場合に限られる。

方法3:Hono JWKミドルウェア

プロジェクトが非対称鍵(RS256またはES256)を使っている場合、HonoのJWKミドルウェアを直接使える。JWKSを一度取得してキャッシュし、以降のリクエストはローカルで検証する。

このミドルウェアは対称アルゴリズム(HS256)を拒否する。 プロジェクトがHS256を使っている状態でこのミドルウェアを適用すると、アルゴリズムの不一致を示すエラーメッセージなしに全リクエストが401を返す。使用前にプロジェクトの鍵方式を確認すること。

import { Hono, type Context, type Next } from 'hono'
import { jwk } from 'hono/jwk'

type JwtPayload = {
  sub: string
  email: string
  role: string
  exp: number
  iss: string
}

type Variables = {
  jwtPayload: JwtPayload
}

const app = new Hono<{ Variables: Variables }>()

app.use('/api/*', jwk({
  jwks_uri: `${process.env.SUPABASE_URL}/auth/v1/.well-known/jwks.json`,
}))

// jwkミドルウェアは署名とexpを検証するがissは確認しない——別ミドルウェアで追加する
app.use('/api/*', async (c: Context<{ Variables: Variables }>, next: Next) => {
  const payload = c.get('jwtPayload')
  if (payload?.iss !== `${process.env.SUPABASE_URL}/auth/v1`) {
    return c.json({ error: 'Unauthorized' }, 401)
  }
  await next()
})

app.get('/api/profile', (c) => {
  const payload = c.get('jwtPayload')
  return c.json({
    userId: payload.sub,
    email: payload.email,
  })
})

export default app

jwkミドルウェアは署名・exp・アルゴリズムを検証するがissは自動的に確認しない。2番目のミドルウェアがその確認を担う。SupabaseはJWKSエンドポイントをエッジで10分間キャッシュしている。アプリケーション側でこれより長くキャッシュすると、鍵ローテーション後に有効なトークンを拒否することがある。

方法4:JWT Secret(レガシー、HS256専用)

プロジェクトがHS256を使っており、APIに@supabase/supabase-jsを入れたくない場合は、プロジェクト設定のJWT Secretを使って直接トークンを検証できる。

import { Hono } from 'hono'
import { jwt } from 'hono/jwt'
import type { JwtVariables } from 'hono/jwt'

type Variables = JwtVariables

const app = new Hono<{ Variables: Variables }>()

app.use('/api/*', jwt({
  secret: process.env.SUPABASE_JWT_SECRET!,
  alg: 'HS256',
}))

app.get('/api/profile', (c) => {
  const payload = c.get('jwtPayload')
  return c.json({ userId: payload.sub })
})

export default app

新規プロジェクトにはこの方法を避けること。 対称鍵はトークンを検証するすべてのサーバーに配布しなければならない。秘密鍵を持っている者はトークンの発行も検証もできてしまう。非対称鍵方式ではこれらを分離できる。秘密鍵はSupabase Authが保持し、検証にはJWKSの公開鍵だけを使えばいい。

JWTをSupabase RLSで使う

APIからSupabaseにクエリを投げる際は、ユーザーのJWTを転送してRLSポリシーが機能するようにする。起動時に生成した共有クライアントではなく、リクエストごとにスコープ付きクライアントを作成する。

import { createClient } from '@supabase/supabase-js'

app.get('/api/posts', async (c) => {
  const token = c.get('token')

  const userSupabase = createClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_ANON_KEY!,
    {
      global: {
        headers: { Authorization: `Bearer ${token}` },
      },
    }
  )

  const { data, error } = await userSupabase.from('posts').select('*')
  if (error) return c.json({ error: error.message }, 500)
  return c.json(data)
})

これによりPostgresはクエリを認証済みユーザーとして実行する。RLSポリシーがauth.uid()user_idを比較するとき、auth.uid()は転送されたJWTのsubクレームから導出される。

@supabase/serverを使っている場合、c.var.supabaseContext.supabaseはすでにJWTを転送しているため、リクエストごとのクライアント作成は不要だ。

RLSの失敗——有効なJWTを送っているのにクエリが空を返す、INSERTがポリシー違反で弾かれるなど——のデバッグ方法についてはSupabaseのRLSが動かない——Unified Logsを使ったデバッグ手順を参照してほしい。

認証エラーを処理する

認証の失敗には401、認可の失敗には403を返す。401はトークンが存在しない・無効・期限切れを意味する。403はトークンが有効であるにもかかわらず、リクエストされたリソースへのアクセス権がないことを意味する。

よくある失敗ケースとその原因:

| 症状 | 原因 | |---|---| | Authorizationヘッダーがない | クライアントがトークンを送っていない | | Bearer プレフィックスがない | クライアントがスキームプレフィックスなしでトークンを送っている | | 署名が無効 | トークンが改ざんされている、またはミドルウェアが間違ったSUPABASE_URLを参照している | | expが期限切れ | トークンが失効している。クライアントがsupabase.auth.refreshSession()を呼ぶ必要がある | | 別のSupabaseプロジェクトのJWT | issクレームが一致しない | | HS256プロジェクトでgetClaims()のキャッシュ効果がない | サーバーサイドにフォールバックするためgetUser()と同等のネットワーク呼び出しが発生する | | HS256プロジェクトでJWKミドルウェアが全リクエストを401にする | jwkミドルウェアは対称アルゴリズムを拒否する |

失敗の理由をレスポンスに含めないようにする。サーバー側のログに記録し、クライアントには汎用的なUnauthorizedのみを返す。方法2のrequireAuth関数はすでにこのパターンに従っており、原因によらずすべての早期リターンが同じボディで401を返す。

403レスポンス——ユーザーは認証済みだが特定のリソースにアクセスできない——は、ミドルウェアではなくルートハンドラ内でクレームやデータベースのパーミッションを確認して返す。

よくある間違い

decodeをverifyと混同する。 JWTをデコードすると署名を検証せずにペイロードを読み取れる。JWTのように見えるどんな文字列もデコード自体は成功する。既知の鍵で署名を検証してからでなければ、クレームを信用してはいけない。

サーバー側でgetSession()を使う。 getSession()は現在のセッションコンテキストに保存されているものを返す。サーバーにはセッションコンテキストがない。Authorizationヘッダーから取り出したJWTをgetClaims(token)またはgetUser(token)に明示的に渡すこと。

サービスロールキーをユーザー認証に使う。 サービスロールキーはRLSを完全にバイパスする。管理者向けの操作用であり、APIリクエストのユーザー識別には使わない。

リクエストをまたいでSupabaseクライアントを共有する。 起動時に作成した単一クライアントに毎リクエスト異なるユーザーのJWTをヘッダーとして渡すと、RLSの適用が不安定になる。ユーザースコープのデータベースアクセスが必要な場合はリクエストごとにクライアントを作成する。方法2の共有クライアントはgetClaims()専用であり、ユーザーの状態を持たないため共有しても安全だ。

issクレームを検証しない。 正しい鍵方式で署名されていても、別のSupabaseプロジェクトが発行したJWTはissを確認しないミドルウェアでは受け入れてしまう。これは実際に起こりやすいミスだ——ステージング環境のAPIがステージングのSupabaseプロジェクトを向いている状態で、フロントエンドは本番のJWTを送り続けていたことがある。両プロジェクトは同じアルゴリズムを使っていたため署名は検証を通った。しかしissが一致しなかった。getClaims()はその不一致を検出してエラーを返した——だがissチェックのないJWKS検証だけならそのトークンを受け入れていた。@supabase/serverissを自動的に検証する。方法2と方法3はこの理由から明示的なissチェックを含めている。

JWT検証を認可と混同する。 検証済みのJWTはトークンが本物で期限切れでないことを意味する。そのユーザーがリクエストされたリソースにアクセスできることを意味しない。user_id = auth.uid()をRLSポリシーで確認するといったリソースレベルの認可は、別のステップとして実施する。

ミドルウェアをテストする

正常系と代表的な失敗ケースを両方カバーするテストを書く:

import { describe, it, expect } from 'vitest'
import app from './app'

describe('auth middleware', () => {
  it('Authorizationヘッダーがない場合は401を返す', async () => {
    const res = await app.request('/api/profile')
    expect(res.status).toBe(401)
  })

  it('スキームがBearerでない場合は401を返す', async () => {
    const res = await app.request('/api/profile', {
      headers: { Authorization: 'Token abc123' },
    })
    expect(res.status).toBe(401)
  })

  it('構造的に不正なJWTは401を返す', async () => {
    const res = await app.request('/api/profile', {
      headers: { Authorization: 'Bearer not.a.jwt' },
    })
    expect(res.status).toBe(401)
  })

  it('有効なJWTは200を返す', async () => {
    const res = await app.request('/api/profile', {
      headers: { Authorization: `Bearer ${process.env.TEST_JWT}` },
    })
    expect(res.status).toBe(200)
  })
})

改ざんトークンのテストが特に重要だ。実際のトークンをデコードしてペイロードを書き換え、再署名なしで再エンコードした文字列を送信し、ミドルウェアが401を返すことを確認する。このテストがパスしなければ、デコードだけしてverifyしていないことになる。