Skip to content

How To Secure Routes

Use this guide when you need to protect application pages, API endpoints, or admin-style actions with Seamless Auth.

The important rule is simple: the frontend can improve the user experience, but the backend must enforce authorization.


LayerPurposeSource of truth
React route guardHide or redirect UI for signed-out usersConvenience only
Backend requireAuth(...)Reject unauthenticated API requestsYes
Backend requireRole(...)Reject users without required rolesYes
Step-up authRequire fresh verification before sensitive auth/admin operationsAuth API

For normal app APIs, start with requireAuth(...). Add requireRole(...) when the route has a role or permission boundary.


The React SDK calls ${apiHost}/auth/.... Your backend should mount the Express adapter at /auth.

import cookieParser from 'cookie-parser'
import express from 'express'
import { createSeamlessAuthServer } from '@seamless-auth/express'
const app = express()
const cookieSecret = process.env.COOKIE_SIGNING_KEY!
app.use(express.json())
app.use(cookieParser())
app.use(
'/auth',
createSeamlessAuthServer({
authServerUrl: process.env.AUTH_SERVER_URL!,
cookieSecret,
serviceSecret: process.env.SERVICE_SECRET!,
issuer: process.env.JWT_ISSUER!,
audience: process.env.JWT_AUDIENCE!,
jwksKid: process.env.SEAMLESS_JWKS_KID,
}),
)

createSeamlessAuthServer(...) owns the auth route proxy and cookie refresh behavior. App routes still need their own guards.


Step 2: Protect An Authenticated API Route

Section titled “Step 2: Protect An Authenticated API Route”
import { requireAuth } from '@seamless-auth/express'
app.get('/api/me', requireAuth({ cookieSecret }), (req, res) => {
res.json({
user: req.user,
})
})

requireAuth({ cookieSecret }) verifies the signed access cookie and attaches req.user.

The decoded user has:

type SeamlessAuthUser = {
id: string
sub: string
roles: string[]
email: string
phone: string
iat?: number
exp?: number
}

If the access cookie is missing, invalid, or expired, the route returns 401.


requireRole(...) performs authorization only. Put it after requireAuth(...).

import { requireAuth, requireRole } from '@seamless-auth/express'
app.get(
'/api/admin/users',
requireAuth({ cookieSecret }),
requireRole('admin:read'),
async (req, res) => {
res.json({ users: await listUsers() })
},
)
app.post(
'/api/admin/users',
requireAuth({ cookieSecret }),
requireRole('admin:write'),
async (req, res) => {
const user = await createUser(req.body)
res.status(201).json({ user })
},
)

If the user is authenticated but missing the required role, the route returns 403.

Scoped roles are understood:

Granted roleSatisfies
adminadmin, admin:read, admin:write
admin:writeadmin:write, admin:read
admin:readadmin:read
admin:*admin and any admin:* requirement

Use exact, narrow role names for new protected routes. Keep broad roles such as admin for legacy or intentionally broad access.


React guards are for navigation and UX. They are not a replacement for backend authorization.

import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '@seamless-auth/react'
export function RequireSignedIn() {
const { loading, isAuthenticated } = useAuth()
if (loading) return null
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <Outlet />
}

Use it in your router:

<Route element={<RequireSignedIn />}>
<Route path="/account" element={<AccountPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>

Use hasScopedRole(...) when the UI should reflect the same scoped role behavior as the backend.

import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '@seamless-auth/react'
export function RequireAdminRead() {
const { loading, isAuthenticated, hasScopedRole } = useAuth()
if (loading) return null
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
if (!hasScopedRole('admin:read')) {
return <Navigate to="/" replace />
}
return <Outlet />
}

Then keep the matching backend guard:

app.get(
'/api/admin/report',
requireAuth({ cookieSecret }),
requireRole('admin:read'),
async (_req, res) => {
res.json({ report: await buildAdminReport() })
},
)

That pairing keeps frontend navigation pleasant and backend access explicit.


Step 6: Call Protected Routes With Cookies

Section titled “Step 6: Call Protected Routes With Cookies”

When the frontend and backend are on different origins, include credentials.

const response = await fetch('http://localhost:3000/api/admin/report', {
credentials: 'include',
})
if (response.status === 401) {
// route the user to sign in
}
if (response.status === 403) {
// show a missing-permission state
}

Also confirm the auth API and backend CORS/cookie settings allow the browser origin you are using. For auth API CORS, check APP_ORIGINS. For WebAuthn origin checks, check ORIGINS.


Step 7: Use Step-Up For Sensitive Auth Actions

Section titled “Step 7: Use Step-Up For Sensitive Auth Actions”

Step-up verifies that an already signed-in user has completed fresh authentication.

In React:

import { useAuth } from '@seamless-auth/react'
function SensitiveActionButton() {
const { refreshStepUpStatus, verifyStepUpWithPasskey } = useAuth()
async function run() {
const status = await refreshStepUpStatus()
const fresh = status?.fresh ? true : (await verifyStepUpWithPasskey()).success
if (!fresh) return
await fetch('/auth/admin/users/user_123/recovery/device-replacement', {
method: 'POST',
credentials: 'include',
})
}
return <button onClick={() => void run()}>Recover device</button>
}

The current Express requireAuth(...) middleware does not enforce step-up freshness for arbitrary app routes. Use step-up for built-in auth/admin operations that require it, and add an explicit server-side freshness check before relying on it for custom app-specific sensitive actions.


Organizations are part of Seamless Auth, but app data authorization is still your backend’s job.

Use Seamless Auth organization routes to manage organizations and memberships. For app-specific tenant data, check the authenticated user against the organization or membership rules your app owns before returning data.

app.get(
'/api/organizations/:organizationId/projects',
requireAuth({ cookieSecret }),
async (req, res) => {
const allowed = await canReadProjects({
userId: req.user.id,
organizationId: req.params.organizationId,
})
if (!allowed) {
res.status(403).json({ error: 'Insufficient organization access' })
return
}
res.json({
projects: await listProjects(req.params.organizationId),
})
},
)

Do not rely on a hidden organization selector in the UI as the only tenant boundary.


  • Mount createSeamlessAuthServer(...) at /auth
  • Run cookieParser() before protected app routes
  • Use requireAuth({ cookieSecret }) for authenticated API routes
  • Put requireRole(...) after requireAuth(...)
  • Use scoped roles such as admin:read and admin:write for admin-like routes
  • Use React guards only for UX
  • Send protected browser requests with credentials: 'include'
  • Treat organization and tenant access as backend authorization