@ouim/logto-authkit is a batteries-included auth toolkit for Logto-powered React apps.
It wraps @logto/react with the pieces most teams end up building anyway:
- a higher-level React provider and hook
- ready-made sign-in, callback, and account UI
- backend token verification for Node and Next.js
- bundler fixes for the
joseedge cases that usually slow setup down
If you want Logto without re-assembling the same frontend and backend auth plumbing from scratch, this package is the opinionated fast path.
Wanna try it? checkout the library live on: tstore.ouim.me & mocka.ouim.me
AuthProviderfor wiring Logto into your app with less boilerplateuseAuthfor user state, auth actions, and route protection patternsUserCenterfor a production-ready account dropdownCallbackPagefor redirect and popup callback handlingSignInPagefor dedicated/signinroutesSignInButtonfor drop-in sign-in triggers- popup sign-in support
- guest mode support
- custom navigation support for SPA routers
- JWT verification against Logto JWKS
- Express middleware via
createExpressAuthMiddleware - Next.js request verification via
verifyNextAuth - generic
verifyAuthhelper for custom servers and handlers - optional scope checks
- cookie and bearer-token extraction
- guest-aware auth context support
- Vite config helpers
- Webpack config helpers
- Next.js config helpers
- a dedicated
bundler-configentrypoint for build-time imports
- Faster first integration: frontend and backend auth can be wired from one package.
- Better defaults: common auth screens and account UI are already handled.
- Less glue code: cookie syncing, callback handling, popup flows, and request verification are built in.
- Easier adoption: you still keep Logto underneath, so you are not boxed into a custom auth system.
npm install @ouim/logto-authkit @logto/reactPeer dependencies:
reactreact-dom@logto/react
The package currently declares compatibility with:
- Node.js
18.18+,20.x,22.x, and24.x - React
17.x,18.x, and19.x @logto/react3.xand4.x
GitHub Actions runs the default validation gate on Node 24, which is the current Active LTS line as of March 29, 2026. The published engines field expresses the broader compatibility policy, while CI stays intentionally lighter for day-to-day pull requests.
The example_app/ directory contains a complete Vite React playground:
- Frontend:
AuthProvider,useAuth,UserCenter, popup sign-in, guest mode - Backend: Express.js with
createExpressAuthMiddleware, scope checks, CSRF protection - Setup: See example_app/README.md
examples/nextjs-app-router/ demonstrates:
AuthProviderwrapping the app- Sign-in route with
SignInPage - Callback route with
CallbackPage - Protected API route using
verifyNextAuth - Role-based authorization
- See examples/nextjs-app-router/README.md
smoke-fixtures/ contains automated tests for:
- Vite + React integration
- Next.js App Router integration
- React Router integration
- Node.js backend verification
- Bundler compatibility (CommonJS + ESM)
These validate the package works across different bundler and framework combinations.
- docs/notes/ contains working implementation notes and architecture decisions
- AGENTS.md documents how AI agents can contribute to this project
import { AuthProvider } from '@ouim/logto-authkit'
const logtoConfig = {
endpoint: 'https://your-tenant.logto.app',
appId: 'your-app-id',
resources: ['https://your-api.example.com'],
}
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<AuthProvider config={logtoConfig} callbackUrl="http://localhost:3000/callback">
{children}
</AuthProvider>
)
}import { CallbackPage } from '@ouim/logto-authkit'
export default function CallbackRoute() {
return <CallbackPage />
}import { SignInPage } from '@ouim/logto-authkit'
export default function SignInRoute() {
return <SignInPage />
}import { useAuth } from '@ouim/logto-authkit'
export function Dashboard() {
const { user, isLoadingUser, signIn, signOut } = useAuth()
if (isLoadingUser) return <div>Loading...</div>
if (!user) return <button onClick={() => signIn()}>Sign in</button>
return (
<div>
<p>Welcome, {user.name ?? user.id}</p>
<button onClick={() => signOut()}>Sign out</button>
</div>
)
}import { UserCenter } from '@ouim/logto-authkit'
export function Navbar() {
return (
<nav className="flex items-center justify-between h-16 px-4 border-b">
<div className="font-bold">MyApp</div>
<UserCenter />
</nav>
)
}Main provider for the package. It wraps Logto, manages auth refresh, and keeps the browser cookie in sync for backend verification.
Props:
config: Logto config objectcallbackUrl?: default auth callback URLcustomNavigate?: custom navigation function for React Router, Next.js, or other SPA routersenablePopupSignIn?: enables popup-based sign-in flowonTokenRefresh?: called when an already-authenticated session receives a different access tokenonAuthError?: called when auth loading hits a transient or definitive auth erroronSignOut?: called immediately before the provider initiates local or global sign-out
Example with custom router navigation:
<AuthProvider config={logtoConfig} callbackUrl="/callback" customNavigate={url => router.push(url)} enablePopupSignIn>
<App />
</AuthProvider>Lifecycle callback example:
<AuthProvider
config={logtoConfig}
onTokenRefresh={({ expiresAt }) => analytics.track('token_refreshed', { expiresAt })}
onAuthError={({ error, isTransient }) => console.error('auth error', { message: error.message, isTransient })}
onSignOut={({ reason }) => analytics.track('signed_out', { reason })}
>
<App />
</AuthProvider>Returns:
userisLoadingUsersignInsignOutrefreshAuthenablePopupSignIn
You can also use it for lightweight route protection:
const { user } = useAuth({
middleware: 'auth',
redirectTo: '/signin',
})Guest-only route example:
const auth = useAuth({
middleware: 'guest',
redirectIfAuthenticated: '/dashboard',
})Use usePermission() for client-side conditional rendering when the current frontend user claims already include permission data.
import { usePermission } from '@ouim/logto-authkit'
function AdminActions() {
const canManageUsers = usePermission('manage:users')
if (!canManageUsers) {
return null
}
return <button>Invite user</button>
}Notes:
usePermission()reads theuserobject fromAuthProvider, so it reflects frontend claims only.- By default it checks
permissions, thenscope, thenscp. - Pass
claimKeysif your tenant emits permissions under a custom claim name such ashttps://example.com/permissions. - While auth state is loading the hook returns
falseso restricted UI does not flash before claims arrive.
Prebuilt account dropdown for navbars and app shells.
Supports:
- signed-in and signed-out states
- local sign-out by default, or global sign-out when explicitly enabled
- custom account links
- custom theme class names
<UserCenter
signoutCallbackUrl="/"
globalSignOut={false}
additionalPages={[
{ link: '/settings', text: 'Settings' },
{ link: '/billing', text: 'Billing' },
]}
/>Pass globalSignOut={true} only when you explicitly want the account menu to end the user's wider Logto tenant session, not just the current app session.
Drop this onto your callback route to complete the Logto auth flow.
Optional props:
onSuccessonErrorloadingComponentsuccessComponentclassName
Use this when you want a dedicated /signin route that automatically initiates the auth flow. It also supports popup-based sign-in windows.
If you enable popup sign-in on AuthProvider, you should still define a real /signin route that renders SignInPage. The popup window navigates to that route first, and SignInPage is what kicks off the Logto flow inside the popup.
Optional props:
loadingComponenterrorComponentclassName
<SignInPage
className="min-h-screen bg-slate-50"
loadingComponent={<div>Redirecting to Logto...</div>}
errorComponent={error => <div>Could not start sign-in: {error.message}</div>}
/>For cases where you want a reusable trigger instead of manually calling signIn().
import { SignInButton } from '@ouim/logto-authkit'
;<SignInButton />The frontend entrypoint is intentionally browser-oriented. AuthProvider, useAuth, SignInPage, CallbackPage, and UserCenter all rely on client-only behaviors such as cookies, window, popup messaging, focus events, or browser navigation.
Use these rules when integrating into SSR-capable apps:
- Render frontend auth components only from client components.
- Keep
/signinand/callbackas real browser routes, not server-only handlers. - Expect the initial server render to be unauthenticated until the client hydrates and loads the Logto session.
- Do not treat server-rendered auth state from the frontend package as authoritative for backend access control; use the backend verifier helpers for that.
Wrap the router tree in AuthProvider and pass a router-aware customNavigate callback so auth redirects stay inside the SPA router.
'use client'
import { AuthProvider, CallbackPage, SignInPage } from '@ouim/logto-authkit'
import { BrowserRouter, Route, Routes, useNavigate } from 'react-router-dom'
function AuthShell() {
const navigate = useNavigate()
return (
<AuthProvider
config={logtoConfig}
callbackUrl={`${window.location.origin}/callback`}
customNavigate={url => navigate(url)}
enablePopupSignIn
>
<Routes>
<Route path="/signin" element={<SignInPage />} />
<Route path="/callback" element={<CallbackPage />} />
<Route path="/" element={<Dashboard />} />
</Routes>
</AuthProvider>
)
}
export function App() {
return (
<BrowserRouter>
<AuthShell />
</BrowserRouter>
)
}Keep the auth UI behind client components and let the backend subpath handle server-side request verification.
// app/providers.tsx
'use client'
import { AuthProvider } from '@ouim/logto-authkit'
import { useRouter } from 'next/navigation'
export function Providers({ children }: { children: React.ReactNode }) {
const router = useRouter()
return (
<AuthProvider config={logtoConfig} callbackUrl={`${process.env.NEXT_PUBLIC_APP_URL}/callback`} customNavigate={url => router.push(url)}>
{children}
</AuthProvider>
)
}// app/signin/page.tsx
'use client'
import { SignInPage } from '@ouim/logto-authkit'
export default function SignIn() {
return <SignInPage />
}// app/callback/page.tsx
'use client'
import { CallbackPage } from '@ouim/logto-authkit'
export default function Callback() {
return <CallbackPage />
}For protected server routes, route handlers, or middleware, import from @ouim/logto-authkit/server instead of trying to read frontend auth state during SSR.
These React Router and Next.js examples are mirrored by packed smoke fixtures in smoke-fixtures/ so CI catches export or packaging drift against the documented integration patterns.
Import backend helpers from the dedicated subpath:
import { createExpressAuthMiddleware, hasScopes, requireScopes, verifyAuth, verifyNextAuth } from '@ouim/logto-authkit/server'createExpressAuthMiddleware automatically parses cookies for you, so you do not need to add cookie-parser yourself.
import express from 'express'
import { createExpressAuthMiddleware } from '@ouim/logto-authkit/server'
const app = express()
const authMiddleware = createExpressAuthMiddleware({
logtoUrl: 'https://your-tenant.logto.app',
audience: 'https://your-api.example.com',
cookieName: 'logto_authtoken',
requiredScope: 'read:profile',
allowGuest: true,
})
app.get('/api/me', authMiddleware, (req, res) => {
res.json({
userId: req.auth?.userId,
isAuthenticated: req.auth?.isAuthenticated,
isGuest: req.auth?.isGuest,
})
})import { verifyNextAuth } from '@ouim/logto-authkit/server'
export async function GET(request: Request) {
const result = await verifyNextAuth(request, {
logtoUrl: process.env.LOGTO_URL!,
audience: process.env.LOGTO_AUDIENCE!,
allowGuest: false,
})
if (!result.success) {
return Response.json({ error: result.error }, { status: 401 })
}
return Response.json({
userId: result.auth.userId,
payload: result.auth.payload,
})
}For SSR frameworks, make authorization decisions on the server with the backend subpath and pass only the derived result to your rendered UI.
import { verifyAuth } from '@ouim/logto-authkit/server'
export async function loadUserFromRequest(request: Request) {
const auth = await verifyAuth(request, {
logtoUrl: process.env.LOGTO_URL!,
audience: process.env.LOGTO_AUDIENCE!,
allowGuest: true,
})
return {
userId: auth.userId,
isAuthenticated: auth.isAuthenticated,
isGuest: auth.isGuest,
}
}import { verifyAuth } from '@ouim/logto-authkit/server'
const auth = await verifyAuth('your-jwt-token', {
logtoUrl: 'https://your-tenant.logto.app',
audience: 'https://your-api.example.com',
})logtoUrl: requiredaudience: required for protected API resources, accepts either a single audience string or an array of allowed audiencescookieName?: defaults tologto_authtokenrequiredScope?: rejects requests missing the given scopeallowGuest?: enables guest auth fallbackjwksCacheTtlMs?: overrides the default 5 minute in-memory JWKS cache TTLskipJwksCache?: bypasses the in-memory JWKS cache for a single verifier/middleware instance
If you need more than the built-in single requiredScope check, use the backend authorization helpers after token verification:
import { hasRole, hasScopes, requireRole, requireScopes, verifyAuth } from '@ouim/logto-authkit/server'
const auth = await verifyAuth(request, {
logtoUrl: process.env.LOGTO_URL!,
audience: process.env.LOGTO_AUDIENCE!,
})
if (!hasScopes(auth, ['profile:read', 'profile:write'], { mode: 'any' })) {
return Response.json({ error: 'Forbidden' }, { status: 403 })
}
requireScopes(auth, ['profile:read', 'profile:write'])
if (!hasRole(auth, 'admin')) {
return Response.json({ error: 'Forbidden' }, { status: 403 })
}
requireRole(auth, 'admin')Notes:
hasScopes(subject, scopes, { mode })returns a boolean for either a rawAuthPayloador a fullAuthContext.requireScopes(subject, scopes, { mode })throws an error you can map to403.mode: 'all'is the default; usemode: 'any'when any one of the scopes should be enough.- Scope parsing follows the OAuth
scopeclaim convention: a whitespace-delimited string. hasRole(subject, role, { claimKeys })andrequireRole(subject, role, { claimKeys })check role claims without coupling the logic to Express or Next.js.- Role helpers look for
rolesfirst and thenroleby default. If your Logto tenant maps roles into a custom claim, passclaimKeys, for example['https://example.com/roles'].
Backend verification uses a per-process in-memory JWKS cache by default. If you need tighter control during key rotation, debugging, or unusual network setups, you can tune or clear it explicitly:
import { clearJwksCache, invalidateJwksCache, verifyAuth } from '@ouim/logto-authkit/server'
await verifyAuth(token, {
logtoUrl: 'https://your-tenant.logto.app',
audience: 'https://your-api.example.com',
jwksCacheTtlMs: 60_000,
})
invalidateJwksCache('https://your-tenant.logto.app')
clearJwksCache()interface AuthContext {
userId: string | null
isAuthenticated: boolean
payload: AuthPayload | null
isGuest?: boolean
guestId?: string
}This package includes bundler helpers for the jose resolution issues that often show up during Logto integration.
For build-time scripts, prefer the dedicated subpath:
import { viteConfig, getBundlerConfig } from '@ouim/logto-authkit/bundler-config'import { defineConfig } from 'vite'
import { viteConfig } from '@ouim/logto-authkit/bundler-config'
export default defineConfig({
...viteConfig,
})import { webpackConfig } from '@ouim/logto-authkit/bundler-config'
export default {
...webpackConfig,
}import { nextjsConfig } from '@ouim/logto-authkit/bundler-config'
const nextConfig = {
...nextjsConfig,
}
export default nextConfigThe package ships typed frontend and backend exports.
import type {
LogtoUser,
AuthOptions,
AuthContextType,
AuthProviderProps,
CallbackPageProps,
SignInPageProps,
AdditionalPage,
SignInButtonProps,
} from '@ouim/logto-authkit'
import type {
AuthContext,
AuthPayload,
AuthorizationMode,
VerifyAuthOptions,
ExpressRequest,
ExpressResponse,
ExpressNext,
NextRequest,
NextResponse,
} from '@ouim/logto-authkit/server'@ouim/logto-authkit is best thought of as the practical app-layer around Logto:
- Logto remains the identity platform
@logto/reactremains the core SDK- this package adds the missing productized layer most app teams want on day one
If your team eventually needs lower-level control, you can still drop down to the official Logto APIs without throwing your whole auth model away.
This package includes security hardening that most auth libraries leave to you:
- CSRF Protection — Double-submit cookie pattern for backend routes
- Cookie Security — All auth/guest cookies use
Secure,SameSite=Strict - JWKS Cache Invalidation — Automatic key rotation detection
- Payload Validation — JWT fields validated before use
- Network Resilience — Transient errors auto-retry; auth errors fail fast
- Backend Cookie Upgrade — Helper to set
HttpOnlycookies from the backend
See docs/SECURITY_AND_FEATURES.md for details.
usePermissionhook for frontend permission checkscheckRoleAuthorizationandcheckMultiScopeAuthorizationbackend helpers- Provider lifecycle callbacks:
onTokenRefresh,onAuthError,onSignOut - Configurable post-callback redirect on
CallbackPage - Proactive token refresh before expiry
- Configurable JWKS cache TTL
UserCenternow defaults to local sign-out (global: false) — much safer. Opt into global logout withglobalSignOut={true}if you need it.
See docs/MIGRATION_GUIDE.md for upgrade instructions.
- frontend and backend helpers are published from the same package
- backend helpers are exposed from
@ouim/logto-authkit/server - bundler helpers are exposed from
@ouim/logto-authkit/bundler-config
- SECURITY_AND_FEATURES.md — Security hardening, CSRF protection, role-based authorization
- PERMISSIONS_AND_AUTHORIZATION.md — Using
usePermissionand backend authorization helpers - MIGRATION_GUIDE.md — Upgrade guide for v0.1.9+
- src/server/README.md — Backend verification API reference
- CONTRIBUTING.md — Contributing guidelines and branch protection rules
- CI_CD_AND_RELEASES.md — GitHub Actions workflows, smoke tests, release process
- CODE_OF_CONDUCT.md — Community standards
- CHANGELOG.md — Version history and release notes
- SECURITY.md — Vulnerability disclosure policy
Cause: your API is rejecting the browser origin or not allowing credentialed requests, so auth cookies or bearer-token requests never reach the server correctly.
Fix:
- Allow your frontend origin in the backend CORS config.
- If you rely on cookies, enable credentials on both sides: backend
Access-Control-Allow-Credentials: trueand frontendfetch(..., { credentials: 'include' }). - Keep the frontend app, callback route, and backend cookie domain aligned. A cookie set for one host will not be sent to another.
Cause: the backend cannot reach https://<your-logto-host>/oidc/jwks, the logtoUrl is wrong, or the Logto tenant URL includes a typo or wrong environment.
Fix:
- Verify
logtoUrlis the tenant base URL, for examplehttps://your-tenant.logto.app. - Open
https://your-tenant.logto.app/oidc/jwksdirectly and confirm it returns JSON. - Check outbound network rules, proxy settings, and TLS certificates on the server running
verifyAuth/verifyNextAuth. - If failures happen only after a deployment or key rotation, retry once first: the verifier already invalidates stale JWKS cache entries and refetches keys automatically.
Cause: the token's aud claim does not include the API resource identifier you passed as audience in the backend verifier.
Fix:
- Make sure the frontend Logto config requests the same resource in
resources. - Make sure the backend
audiencematches that resource exactly. - If your API accepts multiple resources, pass
audienceas an array to backend helpers. - Decode a failing token and compare its
audclaim with your configuredaudiencevalue instead of assuming they match.
Cause: the browser blocked window.open, usually because the sign-in call was not triggered from a direct user interaction or the site is in a stricter popup policy context.
Fix:
- Trigger popup sign-in from a real click or tap handler.
- Keep a real
/signinroute that rendersSignInPage; popup flow depends on it. - If popup restrictions are unavoidable, disable popup flow and use the default redirect flow instead.
- Test with browser extensions disabled if a popup blocker is interfering during development.
Cause: the app is repeatedly sending unauthenticated users to sign-in without successfully finishing the callback or persisting the token.
Fix:
- Confirm both
/signinand/callbackroutes exist and renderSignInPageandCallbackPage. - Ensure
callbackUrlinAuthProviderexactly matches the redirect URI configured in Logto. - Do not protect the callback route itself with
useAuth({ middleware: 'auth' }). - If you use custom navigation, verify it does not rewrite the callback URL or strip query parameters before
CallbackPageruns. - Check whether auth cookies are being cleared or blocked after callback, especially across different domains, subdomains, or HTTP/non-HTTPS environments.
Cause: when @ouim/logto-authkit is consumed from a local path or symlink, the app can resolve a different React instance than the linked package. That usually shows up as invalid hook calls in Vite apps, or client/server boundary issues in Next.js App Router.
Fix:
- For Vite consumers, dedupe
reactandreact-domand alias them to the app's ownnode_modules. - When merging
viteConfig, mergeresolveandresolve.aliasinstead of replacing them. - For Next.js App Router, make sure your local build preserves the frontend entry's
'use client'directive. - See the dedicated guide: docs/LINKED_LOCAL_PACKAGE_TROUBLESHOOTING.md
MIT

