Back to blog

Hono Supabase Auth: 4 Ways to Verify JWTs and Which One to Use

Compare @supabase/server, getClaims(), Hono's jwk middleware, and jose for verifying Supabase JWTs in a Hono API. Covers CORS setup, RLS preservation, common failures, and a production checklist.

You have Supabase Auth in your frontend and a Hono API acting as the backend. Users sign in, the app gets an access token, and every API request arrives with Authorization: Bearer <token>. Your Hono middleware needs to verify that token before the route handler touches any data.

In May 2026, Supabase released @supabase/server as a public beta. It is a package that handles JWT verification, Supabase client creation, and request context for server frameworks—Hono included. Before that release, a Hono API had to wire this up manually. Now there are four distinct approaches, and which one is right for your project depends on your project's signing algorithm, how much you trust a beta package, and whether you need Supabase dependencies in your API at all.

This article shows all four, explains when each applies, covers how each handles CORS, and ends with a production checklist.

Tested on Node.js 22 and AWS Lambda (nodejs22.x), August 2026. Hono 4.7+, @supabase/supabase-js 2.50+. @supabase/server is in public beta—pin to a specific version and check npm for the latest release before installing.

The architecture and where the trust boundary sits

Before choosing a verification method, it helps to see where verification belongs in the request flow:

Browser
  └─ fetch('/api/posts', { headers: { Authorization: 'Bearer <access_token>' } })
         │
Hono API
  ├─ 1. Extract token from Authorization header
  ├─ 2. Verify JWT signature against Supabase's JWKS
  ├─ 3. Check issuer and expiry
  ├─ 4. Set verified claims on the request context
  └─ 5. Query Postgres using a user-scoped Supabase client
         │
Postgres with Row Level Security
  └─ auth.uid() derived from the sub claim in the forwarded JWT

Two rules follow from this regardless of which method you pick:

Do not trust a user ID from the request body. A user ID in a JSON payload can be anything. The verified sub claim in the JWT is what authentication established.

Do not use a shared admin client for user queries. A client created with the service role key bypasses RLS entirely. Create a per-request client that forwards the access token—Postgres derives the user identity from auth.uid(), which it reads from the JWT you pass.

Which method to use

| Method | HS256 | RS256 / ES256 | Network calls | Best for | |---|---|---|---|---| | @supabase/server | ✓ | ✓ | Depends on key type | New Hono APIs if you accept beta risk | | getClaims() | Fallback¹ | ✓ (JWKS, cached) | First call per key rotation | Most Hono APIs today | | Hono jwk middleware | ✗ | ✓ (JWKS, cached) | First call per key rotation | RS256 / ES256 with fine-grained control | | jose | ✓ | ✓ (JWKS) | First call per key rotation | Multiple issuers, custom caching, or no Supabase SDK |

¹ On HS256 projects, getClaims() falls back to a server-side call equivalent to getUser()—one Auth server network request per call.

The decision turns on your project's signing algorithm. Check it in Supabase dashboard → Project Settings → API → JWT Settings. Projects created before Supabase introduced asymmetric signing defaults commonly use HS256. Newer projects may default to ES256 or RS256. The jwk middleware will silently reject every token if your project uses HS256.

@supabase/server vs @supabase/ssr

Before the implementation: if you searched for "Supabase server package" recently, you may have encountered @supabase/ssr alongside @supabase/server. They are not interchangeable. Supabase's package selection guide describes the distinction.

@supabase/ssr is for SSR frameworks where authentication travels in a cookie—Next.js, SvelteKit, Remix. It handles cookie storage, session refresh, and server-side cookie parsing.

@supabase/server is for API servers where authentication arrives as a Bearer token in the Authorization header. Hono APIs, Express APIs, Deno handlers. It handles JWT verification, client creation, and request context from the token in the header.

@supabase/supabase-js is the base package when you want to handle auth yourself—you manage how the session or token is retrieved and passed.

If your Hono API receives Bearer tokens (not cookies), use @supabase/server or wire it up with @supabase/supabase-js directly. @supabase/ssr is the wrong package for this use case.

Option 1: @supabase/server (public beta)

@supabase/server is the highest-level option. Its Hono adapter handles JWT verification, iss validation, CORS, and creates both a user-scoped client and an admin client on every request.

Environment variables required (different from Options 2–4):

  • SUPABASE_URL — your project URL
  • SUPABASE_PUBLISHABLE_KEY — the client-facing API key (replaces SUPABASE_ANON_KEY in the new key model)
  • SUPABASE_SECRET_KEY — the server-only API key (replaces the service role key)
  • SUPABASE_JWKS — the JWKS JSON for JWT verification (or SUPABASE_JWKS_URL)

Plural forms (SUPABASE_PUBLISHABLE_KEYS, SUPABASE_SECRET_KEYS) accept a named JSON object for multi-project setups and take priority when both exist.

# Pin to a specific version—this package is in public beta
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' adds standard Supabase CORS headers
  // cors: { headers: { 'Access-Control-Allow-Origin': 'https://your-frontend.example.com' } } for custom config
  // cors: 'disabled' if your framework or proxy already handles CORS
  cors: 'default',
}))

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

  // userClaims is null for unauthenticated requests
  if (!userClaims) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

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

export default app

c.var.supabaseContext provides:

  • supabase — a user-scoped client that forwards the JWT to Postgres; RLS applies automatically
  • supabaseAdmin — an admin client using the service role key; bypasses RLS
  • userClaims — verified JWT claims, or null for unauthenticated requests
  • jwtClaims — raw JWT claims including iss, aud, and custom claims
  • authMode — the current authentication mode

The user-scoped supabase client is safe to use for database queries directly—you do not need to create a per-request client manually.

CORS: The adapter handles CORS via the cors option. 'default' applies standard Supabase headers. Use { headers: { ... } } to set specific origins for production. Pass 'disabled' if your reverse proxy or framework already adds CORS headers—do not configure CORS twice.

Beta warning: @supabase/server is in public beta as of August 2026. The adapter API—withSupabase(), c.var.supabaseContext, and field names—may change before GA. Pin the package version and review the official documentation before upgrading.

Option 2: getClaims() middleware

getClaims() verifies the JWT signature against Supabase's JWKS endpoint and reads claims directly from the token. On asymmetric-key projects, after the first request fetches and caches the JWKS keys, subsequent verifications are local—no network call per request. On HS256 projects, it falls back to a server-side call on every request.

This is the recommended stable option for most Hono APIs that need to avoid beta dependencies.

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' }))

// This shared client is used only for getClaims()—it does not carry per-user state
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

  // A JWT signed by a different Supabase project passes signature verification
  // if both projects use the same algorithm. Checking iss prevents this.
  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

Why not getUser()? getUser() makes a network request to the Supabase Auth server on every call. getClaims() verifies locally against cached JWKS keys once the first fetch completes. The difference compounds at request volume. Use getUser() only when you need authoritative confirmation—for example, to detect whether a session has been explicitly revoked.

Option 3: Hono jwk middleware

If your project uses RS256 or ES256, Hono's built-in jwk middleware fetches the JWKS once, caches the public keys, and verifies tokens locally on subsequent requests—without requiring @supabase/supabase-js at all.

This middleware rejects HS256. If your project uses symmetric signing, every request returns 401 with no indication of the algorithm mismatch. Confirm your key type before using it.

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'],  // required—list the asymmetric algorithms your project uses
  verification: {
    iss: `${process.env.SUPABASE_URL}/auth/v1`,  // iss is not checked unless explicitly set
  },
}))

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

export default app

The alg option is required—the middleware will error without it. Pass the algorithms your Supabase project actually uses; RS256 and ES256 cover all asymmetric Supabase configurations. The verification.iss field checks the issuer claim; without it, iss is not validated.

Supabase caches the JWKS endpoint at the edge for 10 minutes. Do not cache the JWKS response longer than that in your application—after a key rotation, valid tokens will be rejected until the cache expires.

Option 4: jose for full control

The jose library handles JWT and JWKS verification directly, without depending on either Supabase's SDK or Hono's built-in middleware. This is the right choice when:

  • You integrate multiple JWT issuers (Supabase plus another provider)
  • You need a custom JWKS cache strategy
  • You want no Supabase SDK dependency in a service that only needs identity
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 checks signature, exp, and iss in one call. The createRemoteJWKSet function handles JWKS fetching and caching internally.

jose does not support HS256 with JWKS. HS256 is a symmetric algorithm—there is no public key to publish in JWKS. For an HS256 project without Supabase SDK, use jwtVerify with the raw JWT secret instead of createRemoteJWKSet:

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,
})

Preserve RLS with a user-scoped client

Options 2 and 3 give you the JWT but not a Supabase client configured to use it. Create a per-request client when you need to query Postgres under the user's identity:

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

app.get('/api/posts', async (c) => {
  // Option 2: requireAuth stores the raw token via c.set('token', token)
  // Option 3: jwk middleware sets jwtPayload but not the raw token—read it from the header
  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 receives the JWT in the Authorization header, sets auth.uid() from the sub claim, and evaluates your RLS policies under that identity.

With Option 1 (@supabase/server), c.var.supabaseContext.supabase already does this—you do not create a separate client.

For common RLS failures—queries returning empty results despite a valid JWT, policies blocking inserts—see Supabase RLS Not Working? How to Debug It with Unified Logs.

Common failure scenarios

| Symptom | Likely cause | |---|---| | All requests return 401, no error logged | Project uses HS256; jwk middleware rejects symmetric algorithms silently | | getClaims() makes a network call on every request | Project uses HS256; falls back to server-side verification | | 401 after deploying to Cloudflare Workers | @supabase/supabase-js may require nodejs_compat in wrangler.toml | | Valid JWT from staging fails in production | iss mismatch—staging and production Supabase URLs differ | | Token passes verification but RLS returns empty results | Using admin client or shared client without forwarding JWT | | Preflight requests fail with CORS error | No cors() middleware, or cors() applied after auth middleware | | exp rejected on a token you just created | Clock skew; jose allows clockTolerance to be set in jwtVerify | | Signature valid but getClaims() returns error | iss check inside getClaims() fails—verify SUPABASE_URL matches the project |

Testing verification

Write tests that cover the valid path and the critical failure cases:

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

describe('auth middleware', () => {
  it('returns 401 when Authorization header is missing', async () => {
    const res = await app.request('/api/profile')
    expect(res.status).toBe(401)
  })

  it('returns 401 for a tampered payload', async () => {
    // Decode a real token, modify the payload, re-encode without re-signing
    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('returns 401 for an expired token', async () => {
    const res = await app.request('/api/profile', {
      headers: { Authorization: `Bearer ${expiredToken}` },
    })
    expect(res.status).toBe(401)
  })

  it('returns 200 for a valid token', async () => {
    const res = await app.request('/api/profile', {
      headers: { Authorization: `Bearer ${process.env.TEST_JWT}` },
    })
    expect(res.status).toBe(200)
  })
})

The tampered-payload test is the most important. If your middleware only decodes the JWT without verifying the signature, this test will pass authentication with forged claims.

Common mistakes

Decoding without verifying. Decoding reads the payload without checking the signature. Any string that looks like a JWT decodes successfully. If you call atob() or Buffer.from(payload, 'base64') without running a verification step first, you are trusting unverified data.

Not checking iss. A JWT signed by a different Supabase project will pass signature verification if both projects use the same algorithm and key type. Without an explicit iss check, a staging token can authenticate against a production API. @supabase/server validates iss automatically. Options 2 and 3 above include explicit iss checks. jose with the issuer option does it in jwtVerify.

Using the service role key for user requests. The service role key bypasses RLS. It belongs in background jobs and administrative operations, not in the middleware that identifies API callers.

Sharing a per-user client across requests. A single createClient instance created at startup cannot safely carry different users' JWTs across concurrent requests. Create a new per-request client when you need user-scoped database access. The shared client used for getClaims() in Option 2 is safe to share because it carries no per-user state.

Applying CORS after auth middleware (Options 2, 3, 4). If cors() runs after your JWT middleware, browsers never receive the CORS headers on a 401 response—the preflight fails and the real request never reaches the server. Apply cors() first. With Option 1 (@supabase/server), CORS is configured via the cors option inside withSupabase()—do not add hono/cors on top of it or you will send duplicate headers.

Leaving beta packages unpinned. @supabase/server is in public beta. Installing @latest means a breaking API change deploys automatically. Pin to a specific version and review the changelog before upgrading.

Production checklist

Before deploying JWT verification to production:

  • [ ] Confirmed signing algorithm (HS256 or RS256/ES256) in Supabase dashboard
  • [ ] Selected verification method that supports that algorithm
  • [ ] iss checked explicitly in middleware (automatic with @supabase/server and jose with issuer option)
  • [ ] CORS configured: cors option in withSupabase() for Option 1; hono/cors before auth middleware for Options 2–4
  • [ ] User-scoped client used for all user-facing database queries (no service role key)
  • [ ] Service role key absent from response bodies and logs
  • [ ] 401 returned for missing, invalid, expired, and tampered tokens—confirmed by test
  • [ ] RLS verified with two distinct users (User A's token cannot read User B's rows)
  • [ ] Token forwarded in Authorization header, not trusted from request body
  • [ ] Package versions pinned in package.json (@supabase/server is beta)
  • [ ] Cloudflare Workers: nodejs_compat flag enabled in wrangler.toml if using @supabase/supabase-js
  • [ ] CORS origin list restricted to known frontend origins (not * in production)

Which method to choose

Start with your project's signing algorithm:

HS256 project:

  • @supabase/server — handles HS256 with an internal server-side call
  • getClaims() — falls back to server-side call on HS256; functional but loses the JWKS cache benefit
  • jose with secret — no network call, no Supabase SDK dependency

RS256 or ES256 project:

  • @supabase/server — recommended if you accept beta risk; handles everything automatically
  • getClaims() — recommended stable choice; JWKS-cached, works with all key types
  • Hono jwk middleware — no Supabase SDK; asymmetric only
  • jose with JWKS — maximum control; suits multiple issuers or custom cache

Migration from older patterns:
If you were calling getUser(token) per request, switch to getClaims(token). On asymmetric-key projects, it eliminates the Auth server network call without changing your verification logic. If you were decoding the JWT without verifying the signature, pick any of the four methods above—all of them verify.