ブログへ戻る

Hono Supabase Auth: JWTを検証する4つの方法と選び方

@supabase/server、getClaims()、HonoのJWKミドルウェア、joseの4方式を比較する。CORS設定、RLSの維持、よくある失敗、本番運用チェックリストを解説。

フロントエンドでSupabase Authを使い、バックエンドにHono APIを置いている構成では、ユーザーがログインするとアクセストークンが発行され、APIへのリクエストごとにAuthorization: Bearer <token>ヘッダーとして送られてくる。Honoのミドルウェアは、そのトークンを検証してからルートハンドラにリクエストを渡さなければならない。

2026年5月、Supabaseは@supabase/serverをパブリックベータとして公開した。JWT検証、Supabaseクライアントの生成、リクエストコンテキストの管理をHonoを含むサーバーフレームワーク向けに提供するパッケージだ。それ以前は、Hono APIでこれらを手動で実装するしかなかった。現在は4種類の方法が存在し、どれを選ぶかはプロジェクトの署名アルゴリズム、ベータパッケージを許容できるか、Supabase SDKへの依存を避けたいかによって変わる。

この記事では4方式を解説し、それぞれの採用場面を示し、それぞれがCORSをどう処理するかを説明したうえで、本番運用チェックリストで締める。

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

アーキテクチャとトラストバウンダリの位置

検証方法を選ぶ前に、リクエストフロー上で検証がどこに位置するかを確認しておく。

ブラウザ
  └─ fetch('/api/posts', { headers: { Authorization: 'Bearer <access_token>' } })
         │
Hono API
  ├─ 1. AuthorizationヘッダーからJWTを取り出す
  ├─ 2. SupabaseのJWKSに対してJWT署名を検証する
  ├─ 3. issuerと有効期限を確認する
  ├─ 4. 検証済みクレームをリクエストコンテキストに設定する
  └─ 5. ユーザースコープのSupabaseクライアントでPostgresにクエリを投げる
         │
Postgres with Row Level Security
  └─ auth.uid() は転送されたJWTのsubクレームから導出される

どの方法を選んでも変わらない2つのルールがある。

リクエストボディのユーザーIDを信用しない。 JSONペイロードに含まれるユーザーIDは誰でも書き換えられる。認証が確立した事実は、検証済みJWTのsubクレームに入っている。

ユーザークエリに共有adminクライアントを使わない。 サービスロールキーで作ったクライアントはRLSを完全にバイパスする。アクセストークンを転送するリクエストごとのクライアントを作成し、Postgresがauth.uid()からユーザーを特定できるようにする。

方法の比較

| 方法 | HS256 | RS256 / ES256 | ネットワーク呼び出し | 採用場面 | |---|---|---|---|---| | @supabase/server | ✓ | ✓ | 鍵方式による | ベータを許容できる新規Hono API | | getClaims() | フォールバック¹ | ✓(JWKS、キャッシュ) | 鍵ローテーション時のみ | 現時点の多くのHono API | | Hono jwkミドルウェア | ✗ | ✓(JWKS、キャッシュ) | 鍵ローテーション時のみ | RS256/ES256で細かく制御したい場合 | | jose | ✓ | ✓(JWKS) | 鍵ローテーション時のみ | 複数issuer、カスタムキャッシュ、SDKなし |

¹ HS256プロジェクトではgetClaims()getUser()と同等のサーバーサイド呼び出しにフォールバックする——リクエストごとに1回のネットワーク呼び出しが発生する。

選択はプロジェクトの署名アルゴリズムで決まる。 Supabaseダッシュボードの Project Settings → API → JWT Settings で確認できる。非対称鍵のデフォルト化前に作成されたプロジェクトはHS256を使っていることが多い。jwkミドルウェアはHS256を静かに拒否するため、鍵方式を確認せずに使うと全リクエストが401になる。

@supabase/server@supabase/ssrの違い

「Supabase server package」で検索すると、@supabase/server@supabase/ssrの両方が見つかることがある。これらは互換ではない。Supabaseのパッケージ選択ガイドに詳細な区別がある。

@supabase/ssr はNext.js、SvelteKit、RemixなどのSSRフレームワーク向けで、認証がCookieに乗ってくる構成を対象としている。Cookieの読み書き、セッションのリフレッシュ、サーバーサイドでのCookieパースを処理する。

@supabase/server はHono APIをはじめとするAPIサーバー向けで、JWTがAuthorizationヘッダーのBearerトークンとして届く構成を対象としている。JWT検証、クライアント生成、リクエストコンテキストをヘッダーから処理する。

@supabase/supabase-js はauth処理を自分で管理したい場合のベースパッケージだ。セッションやトークンの取得・受け渡しは自分で実装する。

HonoのAPIがBearerトークンを受け取る構成なら、@supabase/server@supabase/supabase-jsを直接使う。@supabase/ssrはこのユースケースに合わない。

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

@supabase/serverは最もハイレベルな選択肢だ。HonoアダプターがJWT検証、iss検証、CORS、リクエストごとのユーザースコープクライアントと管理者クライアントの生成をすべて処理する。

必要な環境変数(方法2〜4とは異なる):

  • SUPABASE_URL — プロジェクトURL
  • SUPABASE_PUBLISHABLE_KEY — クライアント向けのAPIキー(新しいキーモデルでSUPABASE_ANON_KEYを置き換える)
  • SUPABASE_SECRET_KEY — サーバー専用のAPIキー(サービスロールキーを置き換える)
  • SUPABASE_JWKS — JWT検証用のJWKS JSON(またはSUPABASE_JWKS_URL

複数プロジェクト構成向けに、名前付きJSONオブジェクト形式の複数形(SUPABASE_PUBLISHABLE_KEYSSUPABASE_SECRET_KEYS)も使用できる。

# パブリックベータのため特定バージョンに固定すること
npm install @supabase/[email protected]
import { Hono } from 'hono'
import { withSupabase } from '@supabase/server/adapters/hono'

const app = new Hono()

app.use('*', withSupabase({
  auth: 'user',
  // cors: 'default' で標準のSupabase CORSヘッダーを追加
  // cors: { headers: { 'Access-Control-Allow-Origin': 'https://your-frontend.example.com' } } でカスタム設定
  // cors: 'disabled' はフレームワークやプロキシがCORSを処理する場合
  cors: 'default',
}))

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

  // userClaimsは未認証リクエストの場合null
  if (!userClaims) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

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

export default app

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

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

データベースクエリにはユーザースコープのsupabaseを使う。リクエストごとのクライアントを手動で作成する必要はない。

CORS: アダプターはcorsオプションでCORSを管理する。'default'は標準のSupabaseヘッダーを適用する。本番環境では{ headers: { ... } }で特定のオリジンを指定する。リバースプロキシやフレームワークがすでにCORSヘッダーを追加している場合は'disabled'を使う——二重設定は避けること。

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

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

getClaims()はSupabaseのJWKSエンドポイントに対してJWT署名を検証し、トークンから直接クレームを読み取る。非対称鍵プロジェクトでは、最初のリクエスト後にJWKS公開鍵がキャッシュされるため、以降はローカルで検証が完結しネットワーク呼び出しが発生しない。HS256プロジェクトでは毎回サーバーサイド呼び出しにフォールバックする。

ベータ依存を避けたい多くのHono APIにとって推奨の安定選択肢だ。

import { Hono, type Context, type Next } from 'hono'
import { cors } from 'hono/cors'
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 }>()

app.use('*', cors({ origin: 'https://your-frontend.example.com' }))

// この共有クライアントはgetClaims()専用——ユーザーごとの状態は持たない
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プロジェクトが同じアルゴリズムを使っていると署名検証を通過してしまう
  // issを確認することでこれを防ぐ
  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公開鍵を使ってローカルで検証する。リクエスト量が多いほど差が積み重なる。getUser()が必要なのは、セッションが明示的に失効していないかをAuthサーバーで確認しなければならない場合だけだ。

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

RS256またはES256を使うプロジェクトなら、HonoのJWKミドルウェアを直接使える。JWKSを一度取得してキャッシュし、以降はローカルで検証する。@supabase/supabase-jsへの依存がない。

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

import { Hono } from 'hono'
import { cors } from 'hono/cors'
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('*', cors({ origin: 'https://your-frontend.example.com' }))

app.use('/api/*', jwk({
  jwks_uri: `${process.env.SUPABASE_URL}/auth/v1/.well-known/jwks.json`,
  alg: ['RS256', 'ES256'],  // 必須——プロジェクトで使用している非対称アルゴリズムを指定する
  verification: {
    iss: `${process.env.SUPABASE_URL}/auth/v1`,  // 指定しない場合issは検証されない
  },
}))

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

export default app

algは必須オプションだ——指定しないとミドルウェアはエラーになる。RS256ES256はSupabaseの非対称鍵設定をすべてカバーする。verification.issはissuerクレームを検証する。指定しない場合、issは確認されない。

SupabaseはJWKSエンドポイントをエッジで10分間キャッシュしている。アプリケーション側でこれより長くキャッシュすると、鍵ローテーション後に有効なトークンを拒否することがある。

方法4:joseで完全制御

joseライブラリはJWTとJWKSの検証をSupabase SDKにもHonoのビルトインミドルウェアにも依存せずに処理できる。以下の場面に適する。

  • Supabaseと別のプロバイダーなど、複数のJWT issuerを統合する
  • JWKSキャッシュ戦略を独自に制御したい
  • IDの確認だけが必要なサービスでSupabase SDKへの依存を避けたい
npm install jose
import { Hono, type Context, type Next } from 'hono'
import { cors } from 'hono/cors'
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'

type SupabaseClaims = JWTPayload & {
  email: string
  role: string
}

type Variables = { claims: SupabaseClaims }

const SUPABASE_JWKS = createRemoteJWKSet(
  new URL(`${process.env.SUPABASE_URL}/auth/v1/.well-known/jwks.json`)
)

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

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

app.use('*', cors({ origin: 'https://your-frontend.example.com' }))

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)

  try {
    const { payload } = await jwtVerify(token, SUPABASE_JWKS, {
      issuer: EXPECTED_ISS,
    })
    c.set('claims', payload as SupabaseClaims)
  } catch (err) {
    console.error('JWT verification failed:', err)
    return c.json({ error: 'Unauthorized' }, 401)
  }

  await next()
}

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

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

export default app

jwtVerifyは署名・expissを一度に確認する。createRemoteJWKSetはJWKSの取得とキャッシュを内部で処理する。

joseはHS256にJWKSを使えない。 HS256は対称アルゴリズムであり、JWKSに公開できる公開鍵が存在しない。SDKなしでHS256プロジェクトを検証したい場合は、createRemoteJWKSetの代わりにJWT Secretを直接使う。

import { jwtVerify } from 'jose'

const secret = new TextEncoder().encode(process.env.SUPABASE_JWT_SECRET!)

const { payload } = await jwtVerify(token, secret, {
  algorithms: ['HS256'],
  issuer: EXPECTED_ISS,
})

RLSをユーザースコープのクライアントで維持する

方法2と方法3はJWTを取得するがSupabaseクライアントは提供しない。ユーザーのIDのもとでPostgresにクエリを投げる場合は、リクエストごとのクライアントを作成する。

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

app.get('/api/posts', async (c) => {
  // 方法2:requireAuthがc.set('token', token)でセットした値
  // 方法3:jwkミドルウェアはjwtPayloadをセットするが生トークンはセットしない——ヘッダーから直接取得する
  const token = c.get('token') ?? c.req.header('Authorization')!.slice(7)

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

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

PostgresはAuthorizationヘッダーのJWTを受け取り、subクレームからauth.uid()を設定してRLSポリシーを評価する。

方法1(@supabase/server)ではc.var.supabaseContext.supabaseがすでにJWTを転送しているため、リクエストごとのクライアントを手動で作成する必要はない。

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

よくある失敗シナリオ

| 症状 | 原因として考えられること | |---|---| | 全リクエストが401、エラーログなし | HS256プロジェクトでjwkミドルウェアを使っている。対称アルゴリズムを静かに拒否する | | getClaims()がリクエストごとにネットワーク呼び出しを行う | HS256プロジェクト。サーバーサイドにフォールバックしている | | Cloudflare Workersにデプロイ後に401 | @supabase/supabase-jsnodejs_compatフラグを必要とする場合がある | | ステージングの有効なJWTが本番で失敗する | iss不一致。ステージングと本番のSupabase URLが異なる | | トークンは検証を通るがRLSで空が返る | adminクライアントまたはJWTを転送していない共有クライアントを使っている | | プリフライトがCORSエラーで失敗する | cors()ミドルウェアがないか、認証ミドルウェアより後に適用されている | | 作成直後のトークンがexpで拒否される | クロックスキュー。josejwtVerifyではclockToleranceで許容範囲を設定できる | | 署名は有効だがgetClaims()がエラーを返す | getClaims()内部のissチェックが失敗している。SUPABASE_URLがプロジェクトと一致しているか確認する |

検証のテスト

正常系と重要な失敗ケースを両方カバーするテストを書く。

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('改ざんされたペイロードは401を返す', async () => {
    // 実際のトークンをデコードしてペイロードを書き換え、再署名なしで再エンコードする
    const [header, , signature] = realToken.split('.')
    const payload = btoa(JSON.stringify({ sub: 'attacker', email: '[email protected]' }))
    const tampered = `${header}.${payload}.${signature}`
    const res = await app.request('/api/profile', {
      headers: { Authorization: `Bearer ${tampered}` },
    })
    expect(res.status).toBe(401)
  })

  it('期限切れトークンは401を返す', async () => {
    const res = await app.request('/api/profile', {
      headers: { Authorization: `Bearer ${expiredToken}` },
    })
    expect(res.status).toBe(401)
  })

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

改ざんペイロードのテストが最も重要だ。ミドルウェアが署名を検証せずにJWTをデコードしているだけなら、このテストは偽造クレームで認証を通過してしまう。

よくある間違い

verifyせずにdecodeする。 デコードは署名を確認せずにペイロードを読み取る。JWTのように見える文字列はデコードだけなら常に成功する。atob()Buffer.from(payload, 'base64')を検証ステップなしに呼んでいる場合、未検証のデータを信用している。

issクレームを確認しない。 別のSupabaseプロジェクトが署名したJWTは、両プロジェクトが同じアルゴリズムと鍵方式を使っていれば署名検証を通過する。明示的なissチェックがなければ、ステージングのトークンが本番のAPIで認証を通る可能性がある。@supabase/serverは自動でissを検証する。方法2と方法3には明示的なissチェックを含めている。joseissuerオプションはjwtVerify内で処理する。

サービスロールキーをユーザーリクエストに使う。 サービスロールキーはRLSをバイパスする。バックグラウンドジョブや管理者向け操作に属するものであり、APIの呼び出し元を識別するミドルウェアに使うものではない。

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

認証ミドルウェアの後にCORSを設定する(方法2〜4)。 cors()がJWTミドルウェアより後に実行されると、401レスポンスにCORSヘッダーが付かない——プリフライトが失敗し、本来のリクエストがサーバーに届かなくなる。cors()を最初に適用すること。方法1(@supabase/server)では、CORSはwithSupabase()corsオプションで設定する——hono/corsを追加で重ねると二重設定になる。

ベータパッケージのバージョンを固定しない。 @supabase/serverはパブリックベータ中だ。@latestでインストールすると、破壊的な変更が自動的にデプロイされる。特定バージョンに固定し、アップグレード前にchangelogを確認すること。

本番運用チェックリスト

本番環境にJWT検証をデプロイする前に確認する:

  • [ ] Supabaseダッシュボードで署名アルゴリズム(HS256かRS256/ES256か)を確認済み
  • [ ] そのアルゴリズムに対応した検証方法を選択済み
  • [ ] issチェックがミドルウェアで明示的に行われている(@supabase/serverjoseissuerオプションは自動)
  • [ ] CORSを設定済み:方法1はwithSupabase()corsオプション、方法2〜4はhono/corsを認証ミドルウェアより前に配置
  • [ ] ユーザー向けのデータベースクエリにユーザースコープのクライアントを使っている(サービスロールキーなし)
  • [ ] サービスロールキーがレスポンスボディとログに含まれていない
  • [ ] トークンがない・無効・期限切れ・改ざんされた場合に401を返すことをテストで確認済み
  • [ ] RLSがユーザーAとユーザーBで独立して機能することを確認済み(AのトークンでBのrowを取得できない)
  • [ ] ユーザーIDをリクエストボディから信頼せず、検証済みJWTのsubクレームを使っている
  • [ ] package.jsonでバージョンを固定済み(@supabase/serverはベータ)
  • [ ] Cloudflare Workers:@supabase/supabase-jsを使う場合はwrangler.tomlnodejs_compatフラグを有効化済み
  • [ ] CORSのorigin設定が本番環境の既知のフロントエンドURLに限定されている(*は本番非推奨)

方法の選び方まとめ

プロジェクトの署名アルゴリズムから始める。

HS256プロジェクト:

  • @supabase/server — HS256をサーバーサイド呼び出しで処理する
  • getClaims() — HS256でサーバーサイドにフォールバック。機能はするがJWKSキャッシュの恩恵がない
  • jose with secret — ネットワーク呼び出しなし、Supabase SDKへの依存なし

RS256またはES256プロジェクト:

  • @supabase/server — ベータリスクを許容できるなら推奨。すべてを自動処理する
  • getClaims() — 安定した推奨選択肢。すべての鍵方式でJWKSキャッシュが有効
  • Hono jwkミドルウェア — Supabase SDKなし。非対称鍵専用
  • jose with JWKS — 最大の制御性。複数issuerやカスタムキャッシュが必要な場合

古い実装からの移行: リクエストごとにgetUser(token)を呼んでいた場合はgetClaims(token)に切り替える。非対称鍵プロジェクトでは、検証ロジックを変えずにAuthサーバーへのネットワーク呼び出しをなくせる。JWTを署名検証なしでデコードだけしていた場合は、上記4方式のどれかに移行する——すべて署名を検証する。