
Authentication for Astro Sites - Part 4
Part 4 of 4. Start with Authentication for Astro Sites.
Protecting API routes and endpoints
The middleware chapter covered page-level protection. This chapter is about API endpoints — the JSON routes consumed by your frontend islands and any external clients.
Server-side auth in Astro API routes
Astro API routes live in src/pages/api/*.ts. Export HTTP method handlers with the APIRoute type:
// src/pages/api/me.ts
import type { APIRoute } from 'astro'
export const prerender = false
export const GET: APIRoute = async (context) => {
const { isAuthenticated, userId } = context.locals.auth()
if (!isAuthenticated) {
return new Response(JSON.stringify({ error: 'unauthenticated' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
return new Response(JSON.stringify({ userId }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}The context.locals.auth() API is identical to pages. Same has(), orgId, getToken() methods. Prerendered API routes bake their response at build time — always mark them SSR if the project default is static.
Example: a protected data endpoint
A realistic example: GET /api/tasks returns tasks scoped to the active organization.
// src/pages/api/tasks.ts
import type { APIRoute } from 'astro'
import { listTasksForOrg } from '../../lib/tasks'
export const prerender = false
export const GET: APIRoute = async (context) => {
const { isAuthenticated, orgId } = context.locals.auth()
if (!isAuthenticated) {
return new Response(JSON.stringify({ error: 'unauthenticated' }), { status: 401 })
}
if (!orgId) {
return new Response(JSON.stringify({ error: 'no_active_org' }), { status: 400 })
}
const tasks = await listTasksForOrg(orgId)
return new Response(JSON.stringify({ tasks }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}Verifying session tokens from external callers
When a service other than your Astro app needs to verify a Clerk session token (for example, a background worker consuming a message), use verifyToken() from @clerk/backend:
// services/worker/verify.ts
import { verifyToken } from '@clerk/backend'
export async function verifyClerkToken(token: string) {
const claims = await verifyToken(token, {
secretKey: process.env.CLERK_SECRET_KEY!,
})
return claims
}For service-to-service auth, call getToken() on the Astro side and send it in an Authorization: Bearer header. The worker verifies it with the same secretKey and reads claims.sub (the Clerk user ID) to act on behalf of the user.
Returning 401 vs redirecting
HTML page routes can redirect to sign-in — the browser follows redirects naturally. JSON API routes cannot; a JSON client cannot "follow" an HTML redirect. Return an explicit status code:
// src/pages/api/secure-data.ts
import type { APIRoute } from 'astro'
export const prerender = false
export const GET: APIRoute = async (context) => {
const { isAuthenticated } = context.locals.auth()
if (!isAuthenticated) {
// JSON client → 401 with JSON body
return new Response(JSON.stringify({ error: 'unauthenticated' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
// ...
return new Response(JSON.stringify({ data: '...' }))
}auth.protect() from the middleware chapter handles this automatically — it reads the Accept header and returns 401 for JSON clients, redirect for HTML clients.
Comparing authentication providers for Astro
The fair comparison. Clerk wins on Astro-specific criteria because it has the only first-class Astro SDK with Organizations and prebuilt UI. Competitors score on other dimensions — enterprise SSO breadth, pricing, ecosystem lock-in.
Feature matrix overview
Astro-specific SDK support
Clerk
@clerk/astro 3.0.16 is a first-party integration. Official quickstart repo at github.com/clerk/clerk-astro-quickstart. Components work natively in .astro files. Middleware API (clerkMiddleware, createRouteMatcher, organizationSyncOptions) is Astro-specific. Keyless mode lets readers test without signup.
Auth0
No official Astro SDK. Developers use auth0-spa-js (client-only) or roll OAuth manually against the /authorize and /oauth/token endpoints. Universal Login is the hosted UX — a redirect to Auth0's pages. No embeddable components for Astro.
Supabase Auth
Official @supabase/ssr with an Astro-specific quickstart. createServerClient() plus a cookie adapter over context.cookies.set() and parseCookieHeader. Still marked beta but production-ready. No prebuilt UI — the React UI library was abandoned in February 2024.
Firebase Authentication
Astro has a Firebase backend guide but no dedicated SDK. firebase-admin does not run on edge runtimes (uses Node TCP). Community library next-firebase-auth-edge fills the gap for Next.js; Astro equivalents are less mature. Setup requires both the client Firebase SDK and the Admin SDK.
Built-in UI and components
Clerk has the richest component set for Astro: <SignIn />, <SignUp />, <UserButton />, <UserProfile />, <OrganizationSwitcher />, <OrganizationProfile />, <OrganizationList />, <CreateOrganization />, <Show /> gating. All render server-side in .astro files.
Auth0 has Universal Login (hosted pages) only for the JavaScript ecosystem. Supabase and Firebase provide no prebuilt UI at present — teams build sign-in forms against the respective JS SDKs. Roll-your-own means building everything from scratch.
Organizations and B2B features
- Clerk: built-in Organizations on the free Hobby plan —
org:admin/org:memberroles, custom permissions, member invitations, and up to 20 members per org. The B2B Authentication add-on adds custom roles, multiple Role Sets, unlimited members, verified domains, and per-organization enterprise SSO linking. - Auth0: Organizations are a feature. B2B Essentials ($150/month) unlocks unlimited orgs. Enterprise Connections (SAML/OIDC SSO) are tenant-level resources: the Free plan includes 1 (with self-service SSO and SCIM, added February 2026), Essentials includes 3, Professional includes 5, and additional connections are $100/month each.
- Supabase: no native Organizations primitive — model them in your schema. Project-level SAML SSO (for end users of your app) requires the Pro plan and above.
- Firebase: multi-tenancy requires upgrading to Google Cloud Identity Platform (paid). Tenant-scoped SSO available on the same paid tier.
Pricing and free tier
Pricing as of June 2026:
- Clerk: Hobby (free, 50K MRUs per app, no credit card — raised from 10K in the February 2026 pricing restructure). Pro $20/month annual ($25 monthly), 50K MRUs included with tiered overage down to $0.012/MRU at 10M+. Business $250/month annual ($300 monthly). B2B Authentication add-on $85/month annual ($100 monthly).
- Auth0: Free (25,000 MAUs, 5 orgs, 1 Enterprise Connection with self-service SSO and SCIM added February 2026; MAUs up from 7,500 in September 2024). B2C Essentials from $35/month. B2B Essentials from $150/month (unlimited orgs, 3 Enterprise Connections included). B2B Professional from $800/month (5 Enterprise Connections). Additional Enterprise Connections $100/month each.
- Supabase Auth: Free (50K MAUs). Pro $25/month (100K MAUs included). Project-level SAML SSO is on the Pro plan and above, billed per SSO-MAU (50 included, then $0.015 each) with no per-connection fee and managed through the Supabase CLI (distinct from the Team/Enterprise-only dashboard SSO for Supabase staff access).
- Firebase Auth: Spark free tier covers 50K MAUs (SAML/OIDC capped at 50 MAU). Identity Platform is the paid upgrade for multi-tenancy.
Developer experience
Qualitatively:
- Clerk: ~5 minute quickstart, clear docs, Astro-native components. Fastest to production for a multi-tenant dashboard.
- Supabase: ~15 minute setup, no UI to build against unless you already have one.
- Auth0: 30+ minutes manually wiring OAuth because there is no Astro SDK. Universal Login limits UX customization.
- Firebase: 30+ minutes with edge-compatibility workarounds.
- Roll-your-own: days to weeks depending on scope.
Decision matrix: when each option fits best
- Choose Clerk when you need prebuilt UI, organizations and RBAC, and a first-class Astro integration. Dashboards, B2B SaaS, multi-tenant apps.
- Choose Supabase Auth when the rest of your stack is already Supabase (Postgres, storage, realtime) and you are willing to build your own sign-in UI.
- Choose Auth0 when you need deep enterprise SSO breadth and have budget for it. Willingness to roll OAuth by hand on Astro.
- Choose Firebase when you are already on Google Cloud and can live with the edge-runtime caveats. Willingness to move to paid Identity Platform for multi-tenancy.
- Roll your own only for narrow API-only services with no user-facing auth and an experienced team.
Common pitfalls and best practices
Cross-cutting gotchas to internalize before shipping. Host-specific deployment pitfalls (Netlify preview key loops, edge-middleware caveats on Vercel) are intentionally scoped out here — see Deploy an Astro app to production for those.
Avoiding hydration mismatches in auth UI
Use <Show /> with isStatic matching the enclosing page's prerender mode. For prerendered pages in a server app (or the default case in a static app), add isStatic={true}. For SSR pages, omit the prop.
Do not conditionally render different nav items based on isSignedIn without handling the loading state. The undefined value from $authStore means "Clerk is still initializing" — render a skeleton there, not the signed-out shell.
Choosing the right output mode
Default for dashboards and B2B apps: output: 'server' with @astrojs/node standalone. Every page is SSR; mark marketing pages export const prerender = true.
Mostly-static sites with a few auth pages: output: 'static' and export const prerender = false on auth routes. Middleware runs only on those routes.
Keeping secrets out of client bundles
Only env vars prefixed with PUBLIC_ are bundled to the client. CLERK_SECRET_KEY stays on the server — the middleware and clerkClient read it at runtime.
Never import @clerk/astro/server into a React island component. It will either fail at build or leak server code into the client bundle. If you see a build error referencing clerkMiddleware inside a React file, the import is wrong.
Handling the initial auth state on page load
$authStore.userId === undefined means loading. null means signed out. A string means signed in. Render a skeleton or loading UI during undefined:
import { useStore } from '@nanostores/react'
import { $authStore } from '@clerk/astro/client'
export default function AuthAwareButton() {
const { userId } = useStore($authStore)
if (userId === undefined) return <button disabled>Loading...</button>
if (userId === null) return <button>Sign in</button>
return <button>View profile</button>
}View transitions and scripts running once
Module scripts (<script> tags without is:inline) run only once per full page load, not on <ClientRouter /> soft navigations. If a script initializes auth-related UI on DOMContentLoaded, it will not re-run on navigation.
Fix: listen to astro:page-load instead:
<script>
document.addEventListener('astro:page-load', () => {
// reinitialize after soft navigation
})
</script>For React islands that must retain state across soft navigations, apply transition:persist to the wrapping element.
The historical issue where Clerk components did not load on ClientRouter soft navigation was fixed in @clerk/astro v2.17.2 and is no longer a concern on v3.x.
Staying current with security patches
CVE-2026-41248 was a route-protection bypass in Clerk's createRouteMatcher: crafted, non-canonical request paths could slip past clerkMiddleware() gating because Clerk normalized the path differently from the underlying router (a CWE-436 interpretation conflict). It affected @clerk/astro before v2.17.10 / v3.0.15 (and was fixed on the 1.x line in v1.5.7). Upgrade to v3.0.15 or v2.17.10 or later immediately if you are on an older version — the official quickstart already pins a patched v3.0.16.
PR #8311 shipped the fix, normalizing URL paths in createPathMatcher across @clerk/astro, @clerk/nextjs, and @clerk/nuxt. It is distinct from the unrelated Next.js x-middleware-subrequest bypass (CVE-2025-29927), which does not affect Astro.
Track the @clerk/astro CHANGELOG.md and Clerk's security blog. Subscribe to GitHub release notifications on clerk/javascript.
Testing authenticated flows
Clerk ships @clerk/testing with Playwright support. The key helpers:
clerkSetup(), called in your Playwright global setup, obtains a Testing Token for the whole suite so automated runs don't trip Clerk's "Bot traffic detected" protection.setupClerkTestingToken()injects that Testing Token into an individual test to bypass bot detection. It does not sign anyone in — you still authenticate the test, for example with theclerk.signIn()helper.- Production Testing Tokens (shipped August 2025) extend Testing Tokens to production instances, so you can run the same suite against production without disabling bot protection.
A minimal Playwright spec:
// tests/dashboard.spec.ts
import { clerk, clerkSetup, setupClerkTestingToken } from '@clerk/testing/playwright'
import { expect, test } from '@playwright/test'
test.beforeAll(async () => {
await clerkSetup()
})
test('authenticated dashboard', async ({ page }) => {
await setupClerkTestingToken({ page }) // bypasses bot detection only
await page.goto('/') // load Clerk on a public page first
await clerk.signIn({
page,
signInParams: {
strategy: 'password',
identifier: process.env.E2E_CLERK_USER_USERNAME!,
password: process.env.E2E_CLERK_USER_PASSWORD!,
},
})
await page.goto('/dashboard')
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
})For local development, use the Clerk Dashboard's Impersonation feature to verify behavior as specific users without signing in manually.
Conclusion and next steps
Summary of key takeaways
- Astro's rendering model requires SSR for any personalized or auth-gated page. Prerendered pages cannot read cookies or invoke middleware at runtime.
@clerk/astrois the only provider with a first-class Astro SDK, an official quickstart repo, and prebuilt components that work directly in.astrofiles.- Organizations plus
organizationSyncOptionsare the canonical path to a multi-tenant dashboard. The URL's slug drives the active organization server-side. - Set
isStaticon control components to match the enclosing page's prerender mode. Default SSR pages need no prop; prerendered pages needisStatic={true}. - Every org-scoped query must be gated server-side on
locals.auth().orgId. Never trust URL parameters or request body fields as the sole tenant identifier. - Upgrade to
@clerk/astrov3.0.15 / v2.17.10 or later to close the CVE-2026-41248createRouteMatcherroute-protection bypass.
Resources for continued learning
- Clerk Astro SDK overview
- Clerk Astro deployment guide — host-specific notes
- Clerk Astro quickstart — GitHub repo
- Astro authentication guide — Astro's official auth docs
- How Clerk works
- Organizations getting started
- Roles and permissions
- Clerk Core 3 upgrade guide
Series conclusion
Building a secure authentication layer in Astro requires a solid understanding of how server-rendered code, statically generated HTML, and client-side hydration interact. By leveraging Clerk's robust SDK, protecting API endpoints systematically, and being mindful of common pitfalls like hydration mismatches, you can construct a resilient authentication infrastructure for your Astro applications.
This concludes our four-part series on adding authentication to Astro sites. We hope this comprehensive guide has equipped you with the knowledge to make informed auth architecture decisions and confidently build secure, multi-tenant web applications with Astro.
Frequently asked questions
In this series
- Authentication for Astro Sites
- Authentication for Astro Sites - Part 2
- Authentication for Astro Sites - Part 3
- Authentication for Astro Sites - Part 4 (you are here)