This module provides JWT verification helpers for Node.js applications using Logto authentication. The verification is done manually by fetching public keys from Logto's JWKS endpoint and verifying JWT claims locally, providing better control and performance with built-in caching.
- ✅ Manual JWT verification with JWKS caching
- ✅ Express.js middleware support
- ✅ Next.js API route support
- ✅ Custom claim validation
- ✅ Scope-based authorization
- ✅ Multiple token sources (cookies + Authorization header)
- ✅ TypeScript support
The backend helpers are included with the main package:
npm install @ouim/logto-authkitUse the backend subpath anywhere you need request-time authorization in SSR frameworks. This module is safe for Node.js server contexts and should be the source of truth for access control in:
- Next.js route handlers
- Next.js middleware
- server-rendered loaders/actions
- Express or custom Node servers
Do not try to infer authoritative server auth state from the frontend package during SSR. The frontend entrypoint is browser-first and hydrates auth state on the client; the backend verifier should own request authentication on the server.
createExpressAuthMiddleware automatically parses cookies for you so there's no need to install or app.use a separate cookie-parser middleware. Just import and mount the helper as shown below.
import { createExpressAuthMiddleware } from '@ouim/logto-authkit/server'
const authMiddleware = createExpressAuthMiddleware({
logtoUrl: 'https://your-logto-domain.com',
audience: ['your-api-resource-identifier', 'your-secondary-resource'], // string or string[]
cookieName: 'logto_authtoken', // optional, defaults to 'logto_authtoken'
requiredScope: 'some_scope', // optional
})
// Use in your Express routes
app.get('/protected', authMiddleware, (req, res) => {
// req.auth contains the authenticated user info
res.json({
message: 'Hello authenticated user!',
userId: req.auth.userId,
isAuthenticated: req.auth.isAuthenticated,
})
})// pages/api/protected.js or app/api/protected/route.js
import { verifyNextAuth } from '@ouim/logto-authkit/server'
export async function GET(request) {
const authResult = await verifyNextAuth(request, {
logtoUrl: 'https://your-logto-domain.com',
audience: 'your-api-resource-identifier', // or ['api-audience', 'fallback-audience']
cookieName: 'logto_authtoken', // optional
requiredScope: 'some_scope', // optional
})
if (!authResult.success) {
return Response.json({ error: authResult.error }, { status: 401 })
}
return Response.json({
message: 'Hello authenticated user!',
userId: authResult.auth.userId,
payload: authResult.auth.payload,
})
}// middleware.js
import { verifyNextAuth } from '@ouim/logto-authkit/server'
import { NextResponse } from 'next/server'
export async function middleware(request) {
// Only apply to API routes that need authentication
if (request.nextUrl.pathname.startsWith('/api/protected')) {
const authResult = await verifyNextAuth(request, {
logtoUrl: process.env.LOGTO_URL,
audience: process.env.LOGTO_AUDIENCE,
})
if (!authResult.success) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Add auth info to headers for the API route
const response = NextResponse.next()
response.headers.set('x-user-id', authResult.auth.userId)
return response
}
}
export const config = {
matcher: '/api/protected/:path*',
}Use @ouim/logto-authkit only in client components such as app/signin/page.tsx, app/callback/page.tsx, or a client-side providers wrapper. Use @ouim/logto-authkit/server in route handlers and middleware. That split avoids hydration confusion and keeps browser-only logic out of the server bundle.
import { verifyAuth } from '@ouim/logto-authkit/server'
// Verify with token string
try {
const auth = await verifyAuth('your-jwt-token', {
logtoUrl: 'https://your-logto-domain.com',
audience: 'your-api-resource-identifier',
})
console.log('User ID:', auth.userId)
} catch (error) {
console.error('Auth failed:', error.message)
}
// Verify with request object
try {
const auth = await verifyAuth(requestObject, {
logtoUrl: 'https://your-logto-domain.com',
audience: 'your-api-resource-identifier',
})
} catch (error) {
console.error('Auth failed:', error.message)
}The verification helpers will look for the JWT token in the following order:
- Cookie:
logto_authtoken(or custom name specified incookieName) - Authorization Header:
Bearer <token>
logtoUrl: Your Logto server URL (required)audience: Your API resource identifier or allowed identifiers (required for protected resource APIs, acceptsstring | string[])cookieName: Custom cookie name (optional, defaults to 'logto_authtoken')requiredScope: Required scope for access (optional)jwksCacheTtlMs: Override the per-process JWKS cache TTL in milliseconds (optional, defaults to 5 minutes)skipJwksCache: Force a fresh JWKS fetch for this verification call instead of reading/writing the in-memory cache (optional)
requiredScope is intentionally narrow. When you need more than one scope, verify the token first and then use the exported helpers:
import { hasScopes, requireScopes, verifyAuth } from '@ouim/logto-authkit/server'
const auth = await verifyAuth(request, {
logtoUrl: 'https://your-logto-domain.com',
audience: 'your-api-resource-identifier',
})
if (!hasScopes(auth, ['read:reports', 'write:reports'], { mode: 'any' })) {
throw new Error('Forbidden')
}
requireScopes(auth, ['read:reports', 'write:reports'])hasScopes(subject, scopes, { mode })returnstrue/falserequireScopes(subject, scopes, { mode })throws a descriptive authorization errormodedefaults to'all'; set'any'to accept any matching scope- Both helpers accept either a raw
AuthPayloador a fullAuthContext
Use the exported role helpers when your access control is role-oriented instead of scope-oriented:
import { hasRole, requireRole, verifyAuth } from '@ouim/logto-authkit/server'
const auth = await verifyAuth(request, {
logtoUrl: 'https://your-logto-domain.com',
audience: 'your-api-resource-identifier',
})
if (!hasRole(auth, 'admin')) {
throw new Error('Forbidden')
}
requireRole(auth, 'admin')hasRole(subject, role, { claimKeys })returnstrue/falserequireRole(subject, role, { claimKeys })throws a descriptive authorization error- Default role claim lookup is
roles, thenrole - If your Logto tenant emits roles under a custom namespaced claim, pass
claimKeysexplicitly - The helpers accept either a raw
AuthPayloador a fullAuthContext
The backend verifier keeps a per-process in-memory JWKS cache by default. That is usually the right tradeoff, but you can now control it explicitly when needed:
- Pass
jwksCacheTtlMsto shorten or extend the cache lifetime for a specific verifier/middleware instance. - Pass
skipJwksCache: trueto force a fresh JWKS fetch for a specific verification call. - Call
invalidateJwksCache(logtoUrl)to drop one tenant's cached JWKS entry. - Call
clearJwksCache()to flush the entire in-memory JWKS cache for the current process.
import { clearJwksCache, invalidateJwksCache, verifyAuth } from '@ouim/logto-authkit/server'
const auth = await verifyAuth(token, {
logtoUrl: 'https://your-logto-domain.com',
audience: 'your-api-resource-identifier',
jwksCacheTtlMs: 60_000,
})
invalidateJwksCache('https://your-logto-domain.com')
clearJwksCache()When authentication is successful, you'll get an AuthContext object:
interface AuthContext {
userId: string // User ID from token
isAuthenticated: boolean // Always true when verification succeeds
payload: AuthPayload // Full JWT payload
}All verification functions will throw errors or return error responses when:
- No token is found
- Token is invalid or expired
- Token doesn't contain required scope
- JWKS verification fails