You have Supabase Auth in your frontend. Your users sign in, supabase.auth.getSession() returns an access token, and your frontend sends it to a Hono API as a Bearer token. The API needs to verify that token before touching any data.
The verification step looks simple—extract the Authorization header, check the JWT, move on. But several decisions sit underneath: Which signing key do you use? Does verification require a network call on every request? How does the verified identity reach your Postgres row-level security policies? And what does @supabase/server—released as public beta in May 2026—handle automatically?
As of August 2026, there are four distinct ways to verify a Supabase JWT in a Hono API. They differ in which signing algorithms they support, how many network calls they make, and how much they automate. This article explains each, shows when to choose which, and provides complete TypeScript middleware you can adapt.
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—check npm for the current version before installing.
Which Supabase JWT verification method should you use?
| Method | HS256 | RS256 / ES256 | Network calls | Best for |
|---|---|---|---|---|
| @supabase/server | ✓ | ✓ | Depends on key type | New Hono APIs (public beta) |
| supabase.auth.getClaims() | Fallback¹ | ✓ (JWKS, cached) | First call per key rotation | Most existing APIs |
| Hono jwk middleware | ✗ | ✓ (JWKS, cached) | First call per key rotation | Fine-grained control |
| Hono jwt middleware | ✓ | ✗ | None | Legacy HS256 projects only |
¹ When your project uses symmetric signing (HS256), getClaims() falls back to a server-side verification call identical to getUser()—one Auth server network request per call. The JWKS performance benefit only applies when asymmetric keys are configured.
Your project's signing algorithm determines which methods are available. Check it in your Supabase dashboard under Project Settings → API → JWT Settings. Projects created before Supabase introduced asymmetric signing defaults likely use HS256. Newer projects may default to ES256 or RS256.
The recommended path for a new Hono API: use @supabase/server if you can accept the public-beta risk, or getClaims() for a stable option that works correctly regardless of your project's key type.
Prerequisites
- Hono 4.7 or later
@supabase/supabase-js2.50 or later- TypeScript with strict mode
- Environment variables:
SUPABASE_URL— your project URL (https://<project-id>.supabase.co)SUPABASE_ANON_KEY— your project'sanonpublic keySUPABASE_JWT_SECRET— only required for the legacyjwtmiddleware approach
Send the access token from the frontend
On the client side, retrieve the current session and include the access token in each API request:
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('Not authenticated')
const response = await fetch('https://your-api.example.com/api/profile', {
headers: {
'Authorization': `Bearer ${session.access_token}`,
},
})
return response.json()
}
getSession() is appropriate here. You are calling it in a browser context to retrieve the token you will forward to your own API—not to validate identity on the server.
Option 1: @supabase/server (public beta)
@supabase/server provides a Hono adapter that handles JWT verification, iss validation, Supabase client creation, and request context setup automatically. It is the highest-level option. See the official announcement for background.
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 contains:
supabase— a user-scoped client that respects RLS policies using the request's JWTsupabaseAdmin— an admin client using the service role key for privileged operationsuserClaims— verified JWT claims, ornullfor unauthenticated requestsjwtClaims— raw JWT claims includingiss,aud, and any custom claimsauthMode— the current authentication mode
Use supabase for user-scoped database queries. The user-scoped client forwards the JWT to Postgres, so RLS policies apply automatically.
Note:
@supabase/serveris in public beta as of August 2026. The adapter API—includingc.var.supabaseContextand the specific field names—may change before general availability. Check the official documentation before upgrading.
Option 2: getClaims() middleware
getClaims() verifies the JWT against Supabase's JWKS endpoint (/.well-known/jwks.json), which is cached. For most requests after the first, this is a local signature check against cached public keys—no network call per request.
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 }>()
// Shared client for JWT verification only—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
// Reject JWTs issued by a different Supabase project
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() sends a network request to the Supabase Auth server on every call. getClaims() verifies the signature locally against cached JWKS keys and reads the claims directly from the token. For a request-heavy API, the difference is significant. Use getUser() only when you need authoritative confirmation of a user's current state—for example, to detect whether a session has been explicitly revoked.
Option 3: Hono JWK middleware
If your project uses asymmetric signing (RS256 or ES256), you can use Hono's built-in jwk middleware directly. It fetches the JWKS once, caches the public keys, and verifies tokens locally on every subsequent request.
This middleware rejects symmetric algorithms (HS256). If your project uses HS256 and you apply this middleware, every request will return 401 with no error indicating the algorithm mismatch. Check your key type before using it.
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 middleware verifies signature and exp but not iss—add that check explicitly
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
The jwk middleware validates the signature, exp, and algorithm. It does not check iss automatically, so the second middleware adds that explicitly. Supabase caches the JWKS endpoint at the edge for 10 minutes—do not cache the JWKS response in your application longer than that, or your API may reject valid tokens after a key rotation.
Option 4: JWT secret (legacy, HS256 only)
If your project uses HS256 and you prefer not to use @supabase/supabase-js in the API at all, you can verify tokens directly using the JWT secret from your project settings.
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
Avoid this for new projects. A symmetric secret must be present on every server that verifies tokens. That creates a distribution and rotation problem—anyone with the secret can issue tokens as well as verify them. Asymmetric signing separates these concerns: Supabase Auth holds the private signing key; your API verifies using the public JWKS only.
Pass the JWT to Supabase RLS
When you query Supabase from your API, use the user's JWT so that row-level security policies apply. Create a client scoped to the request, not a shared global instance:
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)
})
This tells Postgres to run the query as the authenticated user. Your RLS policies check auth.uid() against user_id in the table—auth.uid() is derived from the sub claim in the forwarded JWT.
If you use @supabase/server, c.var.supabaseContext.supabase already forwards the JWT—you do not need to create a separate per-request client.
For common RLS failures—empty results despite a valid JWT, INSERT blocked by a policy violation—see Supabase RLS Not Working? How to Debug It with Unified Logs.
Handle authentication errors
Return 401 for authentication failures and 403 for authorization failures. A 401 means the token is missing, invalid, or expired. A 403 means the token is valid but the user lacks permission for the requested resource.
Common failure cases:
| Symptom | Cause |
|---|---|
| Authorization header missing | Client did not send the token |
| Bearer prefix missing | Client sent the raw token without the scheme prefix |
| Signature invalid | Token was tampered with, or the middleware is pointed at the wrong Supabase URL |
| exp expired | Token has expired; client should call supabase.auth.refreshSession() first |
| Wrong Supabase project | JWT was issued by a different project; iss claim will not match |
| getClaims() on an HS256 project | Falls back to a server-side call (same as getUser()); no JWKS performance benefit |
| JWK middleware returns 401 on all requests | Project uses HS256; the jwk middleware rejects symmetric algorithms |
Do not expose the reason for a failed verification in the error response. Log it server-side and return a generic Unauthorized—the requireAuth function in Option 2 already follows this pattern. Every early return produces 401 with the same body regardless of the specific cause.
For 403 responses—the user is authenticated but cannot access the specific resource—check claims or database permissions inside the route handler, not in the middleware.
Common mistakes
Decoding instead of verifying. Decoding a JWT reads the payload without checking the signature. Any string that looks like a JWT will decode successfully. Never trust decoded claims without first verifying the signature against a known key.
Using getSession() on the server. getSession() returns whatever is stored in the current session context. On the server, there is no session context—call getClaims(token) or getUser(token) and pass the JWT from the Authorization header explicitly.
Using the service role key for user authentication. The service role key bypasses RLS entirely. It is for administrative operations, not for identifying users making API requests.
Sharing a Supabase client with per-request JWTs. If you create a single createClient instance at startup and try to pass a different user JWT per request via the global headers, you can encounter race conditions and incorrect RLS behavior. Create a new per-request client when you need user-scoped database access. The shared client in Option 2 is used only for getClaims()—it does not carry user state and is safe to share.
Not verifying iss. A JWT signed with the correct key type but issued by a different Supabase project will pass signature verification if your middleware does not check the iss claim. This is easy to trigger accidentally: we pointed a staging API at a staging Supabase project while the frontend was still sending production JWTs. Both projects used the same algorithm. The signature verified correctly; iss did not match. getClaims() caught the mismatch and returned an error—but a raw JWKS verification without an explicit iss check would have accepted the token. @supabase/server validates iss for you. Options 2 and 3 above include explicit iss checks for this reason.
Treating JWT verification as authorization. A valid, verified JWT means the token is authentic and has not expired. It does not mean the user has permission to access the requested resource. Resource-level authorization—checking that user_id = auth.uid() in your RLS policy, or validating a role claim—is a separate step.
Test the middleware
Write tests that cover both the valid path and the common 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 when scheme is not Bearer', async () => {
const res = await app.request('/api/profile', {
headers: { Authorization: 'Token abc123' },
})
expect(res.status).toBe(401)
})
it('returns 401 for a structurally invalid JWT', async () => {
const res = await app.request('/api/profile', {
headers: { Authorization: 'Bearer not.a.jwt' },
})
expect(res.status).toBe(401)
})
it('returns 200 for a valid JWT', async () => {
// Use an integration test user or a test-helper token
const res = await app.request('/api/profile', {
headers: { Authorization: `Bearer ${process.env.TEST_JWT}` },
})
expect(res.status).toBe(200)
})
})
For expired-token tests, generate a short-lived JWT using the jose library, or use a Supabase test helper that creates tokens with a controlled exp. The test that matters most is the one that confirms a tampered payload returns 401—decode a real token, modify the payload, re-encode without re-signing, and confirm your middleware rejects it.