honojs-middleware/packages/stytch-auth
Jonathan Haines 9235709060
refactor: composite build (#1230)
* refactor: composite build

* chore(ua-blocker): move demo.ts out of src
2025-06-16 11:23:47 +09:00
..
src feat: Stytch Authentication middleware (#1186) 2025-06-14 05:48:00 +09:00
CHANGELOG.md Version Packages (#1225) 2025-06-14 05:55:25 +09:00
README.md feat: Stytch Authentication middleware (#1186) 2025-06-14 05:48:00 +09:00
package.json Version Packages (#1225) 2025-06-14 05:55:25 +09:00
tsconfig.build.json refactor: composite build (#1230) 2025-06-16 11:23:47 +09:00
tsconfig.json refactor: composite build (#1230) 2025-06-16 11:23:47 +09:00
tsconfig.spec.json feat: Stytch Authentication middleware (#1186) 2025-06-14 05:48:00 +09:00
vitest.config.ts feat: Stytch Authentication middleware (#1186) 2025-06-14 05:48:00 +09:00

README.md

Stytch Auth Middleware for Hono

codecov

A third-party Stytch authentication middleware for Hono. Supports both Consumer and B2B authentication with flexible configuration options.

💡 This package works with Stytch Frontend SDKs and validates sessions they create. By default, it reads JWTs from the stytch_session_jwt cookie. See the Session JWTs guide for more details.

Quick Start

The fastest way to get started with Consumer authentication:

import { Hono } from 'hono'
import { Consumer } from '@hono/stytch-auth'

const app = new Hono()

// Authenticate all routes
app.use('*', Consumer.authenticateSessionLocal())

app.get('/', (c) => {
  const session = Consumer.getStytchSession(c)
  return c.json({ message: `Hello ${session.user_id}!` })
})

export default app

Installation

npm install hono @hono/stytch-auth stytch

Configuration

Set these environment variables before using the middleware:

STYTCH_PROJECT_ID=project-live-xxx-xxx-xxx
STYTCH_PROJECT_SECRET=secret-live-xxx-xxx-xxx

Table of Contents

Consumer Authentication

Basic Consumer Session Auth

Local Authentication (JWT validation only - fastest):

import { Consumer } from '@hono/stytch-auth'

// Validates JWT locally using cached JWKS
app.use('*', Consumer.authenticateSessionLocal())

app.get('/profile', (c) => {
  const session = Consumer.getStytchSession(c)
  return c.json({ sessionId: session.session_id })
})

Remote Authentication (validates with Stytch servers - more data):

import { Consumer } from '@hono/stytch-auth'

// Always calls Stytch servers, returns user data
app.use('*', Consumer.authenticateSessionRemote())

app.get('/profile', (c) => {
  const session = Consumer.getStytchSession(c)
  const user = Consumer.getStytchUser(c)
  return c.json({
    sessionId: session.session_id,
    userId: user.user_id,
  })
})

Consumer OAuth Auth

import { Consumer } from '@hono/stytch-auth'

app.use('*', Consumer.authenticateOAuthToken())

app.get('/api/data', (c) => {
  const { claims, token } = Consumer.getOAuthData(c)
  return c.json({
    subject: claims.subject,
    hasValidToken: !!token,
  })
})

B2B Authentication

Basic B2B Session Auth

Local Authentication:

import { B2B } from '@hono/stytch-auth'

app.use('*', B2B.authenticateSessionLocal())

app.get('/dashboard', (c) => {
  const session = B2B.getStytchSession(c)
  return c.json({
    sessionId: session.member_session_id,
    orgId: session.organization_id,
  })
})

Remote Authentication:

import { B2B } from '@hono/stytch-auth'

app.use('*', B2B.authenticateSessionRemote())

app.get('/dashboard', (c) => {
  const session = B2B.getStytchSession(c)
  const member = B2B.getStytchMember(c)
  const organization = B2B.getStytchOrganization(c)

  return c.json({
    sessionId: session.member_session_id,
    memberEmail: member.email_address,
    orgName: organization.organization_name,
  })
})

B2B OAuth Auth

import { B2B } from '@hono/stytch-auth'

app.use('*', B2B.authenticateOAuthToken())

app.get('/api/org-data', (c) => {
  const { claims, token } = B2B.getOAuthData(c)
  return c.json({
    subject: claims.subject,
    hasValidToken: !!token,
  })
})

B2B Organization Access

After B2B remote authentication, you get access to organization data:

app.use('*', B2B.authenticateSessionRemote())

app.get('/org-settings', (c) => {
  const organization = B2B.getStytchOrganization(c)

  return c.json({
    orgId: organization.organization_id,
    orgName: organization.organization_name,
    // ... other organization fields
  })
})

API Reference

Consumer Methods

Method Description Returns
Consumer.getClient(c) Get Consumer Stytch client Client
Consumer.authenticateSessionLocal(opts?) JWT-only auth middleware MiddlewareHandler
Consumer.authenticateSessionRemote(opts?) Remote session auth middleware MiddlewareHandler
Consumer.authenticateOAuthToken(opts?) OAuth bearer token middleware MiddlewareHandler
Consumer.getStytchSession(c) Get session from context Session
Consumer.getStytchUser(c) Get user from context* User
Consumer.getOAuthData(c) Get OAuth data from context** { claims: ConsumerTokenClaims, token: string }

*Only available after authenticateSessionRemote
**Only available after authenticateOAuthToken

B2B Methods

Method Description Returns
B2B.getClient(c) Get B2B Stytch client B2BClient
B2B.authenticateSessionLocal(opts?) JWT-only auth middleware MiddlewareHandler
B2B.authenticateSessionRemote(opts?) Remote session auth middleware MiddlewareHandler
B2B.authenticateOAuthToken(opts?) B2B OAuth bearer token middleware MiddlewareHandler
B2B.getStytchSession(c) Get B2B session from context MemberSession
B2B.getStytchMember(c) Get member from context* Member
B2B.getStytchOrganization(c) Get organization from context* Organization
B2B.getOAuthData(c) Get B2B OAuth data from context** { claims: B2BTokenClaims, token: string }

*Only available after authenticateSessionRemote
**Only available after authenticateOAuthToken

Configuration Options

Session Authentication Options

{
  getCredential?: (c: Context) => { session_jwt: string } | { session_token: string },
  maxTokenAgeSeconds?: number, // For local auth only
  onError?: (c: Context, error: Error) => Response | void
}

OAuth Authentication Options

{
  getCredential?: (c: Context) => { access_token: string },
  onError?: (c: Context, error: Error) => void
}

Advanced Usage

Accessing Raw Stytch Clients

For advanced operations, access the underlying Stytch clients:

// Consumer operations
app.get('/advanced-user-ops', async (c) => {
  const stytchClient = Consumer.getClient(c)
  const { user } = await stytchClient.users.get({ user_id: 'user-123' })
  return c.json({ user })
})

// B2B operations
app.get('/advanced-org-ops', async (c) => {
  const stytchClient = B2B.getClient(c)
  const { organization } = await stytchClient.organizations.get({
    organization_id: 'org-123',
  })
  return c.json({ organization })
})

Custom Configuration

Custom Cookie Name:

import { getCookie } from 'hono/cookie'

app.use(
  '*',
  Consumer.authenticateSessionLocal({
    getCredential: (c) => ({
      session_jwt: getCookie(c, 'my_session_cookie') ?? '',
    }),
  })
)

Custom Token Age:

app.use(
  '*',
  Consumer.authenticateSessionLocal({
    maxTokenAgeSeconds: 60, // 1 minute
  })
)

Custom Error Handling

Redirect to Login:

app.use(
  '*',
  Consumer.authenticateSessionLocal({
    onError: (c, error) => {
      return c.redirect('/login')
    },
  })
)

Custom Error Response:

import { HTTPException } from 'hono/http-exception'

app.use(
  '*',
  Consumer.authenticateOAuthToken({
    onError: (c, error) => {
      const errorResponse = new Response('Unauthorized', {
        status: 401,
        headers: {
          'WWW-Authenticate': 'Bearer realm="api", error="invalid_token"',
        },
      })
      throw new HTTPException(401, { res: errorResponse })
    },
  })
)

Multiple Authentication Methods

You can use different auth methods for different routes:

const app = new Hono()

// Public routes (no auth)
app.get('/health', (c) => c.json({ status: 'ok' }))

// Less-sensitive routes use local authentication against cached JWT / JWKS
app.get('/profile', Consumer.authenticateSessionLocal(), (c) => {
  const session = Consumer.getStytchSession(c)
  return c.json({ session })
})

// More sensitive routes use remote authentication
app.get('/paymentinfo', Consumer.authenticateSessionRemote(), (c) => {
  const member = B2B.getStytchMember(c)
  const organization = B2B.getStytchOrganization(c)
  return c.json({ member, organization })
})

// OAuth API routes
app.use('/api/*', Consumer.authenticateOAuthToken())
app.get('/api/data', (c) => {
  const { claims } = Consumer.getOAuthData(c)
  return c.json({ subject: claims.subject })
})

Custom Credential Extraction Examples

From Authorization Header:

getCredential: (c) => ({
  session_jwt: c.req.header('Authorization')?.replace('Bearer ', '') ?? '',
})

From Query Parameter:

getCredential: (c) => ({
  session_jwt: c.req.query('token') ?? '',
})

From Custom Header:

getCredential: (c) => ({
  access_token: c.req.header('X-API-Token') ?? '',
})

Troubleshooting

Cloudflare Worker Cache Field issues

The 'cache' field on 'RequestInitializerDict' is not implemented.

In Cloudflare Workers the worker compatibility_date must be set to 2024-11-11 or later in the wranger.jsonc. Workers with earlier compatibility dates can set the cache_option_enabled flag detailed here.


For more information, visit the Stytch documentation or the Hono documentation.