
Authentication for Astro Sites - Part 3
Part 3 of 4. Start with Authentication for Astro Sites.
Session management in Astro
Clerk handles sessions for you. The details matter when you are debugging or integrating with another backend.
How Clerk handles sessions
Clerk uses two cookies:
__client— long-lived, HttpOnly, set on Clerk's Frontend API domain (clerk.yourdomain.com, the CNAME you configure). This is the durable session record.__session— a short-lived, 60-second JWT on your app's domain. The frontend SDK auto-refreshes this token every 50 seconds so it never expires mid-request.
When a tab returns after its __session has expired (a closed laptop, a backgrounded tab), the SDK runs the handshake flow: a brief redirect to Frontend API that exchanges the long-lived __client for a new __session and returns. Users rarely see this happen.
Your Astro app never manages these cookies directly. Astro.locals.auth() reads the current __session, and the SDK handles refresh and handshake transparently. See How Clerk works for the full picture.
Reading the current user on the server
Two APIs: Astro.locals.auth() for auth primitives, Astro.locals.currentUser() for the full user record.
Accessing auth from Astro.locals
Astro.locals.auth() returns the auth object:
---
// src/pages/dashboard.astro
const { isAuthenticated, userId, orgId, orgRole, sessionClaims, has, getToken } =
Astro.locals.auth()
if (!isAuthenticated) {
return Astro.redirect('/sign-in')
}
---
<h1>Dashboard</h1>
<p>User ID: {userId}</p>
{orgId && <p>Active org: {orgId}</p>}It is cheap — no network call. The values come from the verified __session token.
Fetching the full user with currentUser()
Astro.locals.currentUser() makes a Backend API call to fetch the full user record. It deduplicates per request, so calling it multiple times in one render is safe:
---
// src/pages/profile.astro
const user = await Astro.locals.currentUser()
if (!user) return Astro.redirect('/sign-in')
---
<h1>Hello, {user.firstName}</h1>
<p>Email: {user.emailAddresses[0]?.emailAddress}</p>Using the user object in .astro files
The user object includes firstName, lastName, emailAddresses, phoneNumbers, imageUrl, publicMetadata, and privateMetadata. Public metadata is fine to render. Private metadata is server-only — never put it in HTML.
Reading the current user on the client
Client-side access comes from the $userStore nanostore. It updates reactively and works in any framework island:
// src/components/ClientGreeting.tsx
import { useStore } from '@nanostores/react'
import { $userStore } from '@clerk/astro/client'
export default function ClientGreeting() {
const user = useStore($userStore)
if (user === undefined) return <span>Loading...</span>
if (user === null) return <span>Not signed in.</span>
return <span>Hello, {user.firstName}.</span>
}Mount with client:load. The initial undefined state is the loading state — always handle it, or you will flash signed-out UI while Clerk's JS initializes.
Session tokens for backend API calls
getToken() on the auth() object returns the current __session JWT. Use it when calling a separate backend service:
// inside an .astro frontmatter or APIRoute handler
const token = await Astro.locals.auth().getToken()
const response = await fetch('https://api.example.com/tasks', {
headers: { Authorization: `Bearer ${token}` },
})Token templates let you customize the claims for specific integrations: getToken({ template: "supabase" }). The external service verifies the token with Clerk's verifyToken() from @clerk/backend, or with any generic JWKS verifier pointed at your Frontend API's JWKS URL.
Session expiry and refresh
The __session token has a 60-second TTL and is auto-refreshed every 50 seconds. BroadcastChannel syncs sessions across browser tabs so a sign-out in one tab signs the user out everywhere.
Force a refresh when you need fresh session claims (for example, after an out-of-band role change):
const token = await Astro.locals.auth().getToken({ skipCache: true })Session maximum lifetime is a Clerk Dashboard setting. The default maximum is 7 days. Inactivity timeout is disabled by default; admins can enable it in the Dashboard. See session options for tuning.
Working with organizations: building a multi-tenant dashboard
This is the headline chapter for the dashboard use case. Organizations turn a single-tenant signup flow into a B2B-ready multi-tenant app without rolling your own schema.
Why organizations matter for SaaS dashboards
B2B SaaS users belong to multiple companies, teams, or projects. Each one is a tenant with its own members, roles, data, and (often) billing. Modeling that yourself — organizations table, memberships table, per-org roles, invitations, verified domains — is a multi-week project before you write a single business feature.
Clerk's Organizations primitive gives you:
- User-to-org memberships with roles.
- Invitations (email, bulk, or link-based).
- Verified domains so any user with
@yourcompany.comauto-joins (B2B Authentication add-on). - Enterprise SSO binding per organization (B2B Authentication add-on).
- An active organization concept — one org is active per session, synced across tabs via
BroadcastChannel.
Enabling organizations in Clerk
In the Clerk Dashboard, open Organizations → Settings and enable organizations. They are part of Clerk's B2B feature set and are included on every plan, including the free Hobby plan.
The base tier — no add-on, on any plan — includes:
- 100 MROs in production, 50 MROs in development.
- Up to 20 members per organization (5 by default, raisable to 20 in settings).
- The built-in
org:adminandorg:memberroles. - Custom permissions and member invitations.
The B2B Authentication add-on ($85/month annual, $100/month monthly) unlocks:
- Unlimited members per organization.
- Custom roles and additional Role Sets (the base tier ships the built-in roles in a single Role Set).
- Verified domains with automatic invitations.
- Linking enterprise SSO connections to specific organizations.
- Metered MROs beyond the included 100, graduated from $1 each (101 to 1,000) down to $0.60 each above 100,000.
MRO stands for Monthly Retained Organization — an organization with at least 2 members where at least one is an MRU (Monthly Retained User). It is Clerk's B2B pricing metric and replaces MAU-style billing for organizations.
Adding the OrganizationSwitcher component
The switcher is a dropdown that shows the user's current organizations and lets them switch, create, or leave. Drop it in the header:
---
// src/layouts/DashboardHeader.astro
import { OrganizationSwitcher, UserButton } from '@clerk/astro/components'
---
<header class="flex items-center justify-between p-4 border-b">
<a href="/" class="font-bold">Acme Dashboard</a>
<div class="flex items-center gap-4">
<OrganizationSwitcher
hidePersonal={true}
afterSelectOrganizationUrl="/orgs/:slug/dashboard"
afterCreateOrganizationUrl="/orgs/:slug/dashboard"
createOrganizationMode="modal"
/>
<UserButton />
</div>
</header>Key props:
hidePersonal={true}forces users into an organization context. Personal accounts are hidden. Use this for B2B-only apps.afterSelectOrganizationUrltakes a template string with:slug, which Clerk substitutes with the selected org's slug. Pair this withorganizationSyncOptionsbelow.afterCreateOrganizationUrl— where to go after creating a new org.createOrganizationMode—"modal"(default) or"navigation".
Displaying the OrganizationProfile
<OrganizationProfile /> renders the full management UI: general info, members roster with invitations and role management, and (if enabled) billing. Mount it on a catch-all route so Clerk can handle internal navigation:
---
// src/pages/orgs/[slug]/settings/[...rest].astro
export const prerender = false
import { OrganizationProfile } from '@clerk/astro/components'
import Layout from '../../../../layouts/Layout.astro'
const { isAuthenticated } = Astro.locals.auth()
if (!isAuthenticated) return Astro.redirect('/sign-in')
---
<Layout title="Organization settings">
<OrganizationProfile path={`/orgs/${Astro.params.slug}/settings`} />
</Layout>Clerk handles invites, role changes, and removal UI end-to-end. Custom pages can be added via sub-components if you need in-app customization (for example, an "Integrations" tab that is tenant-scoped).
Making routes reflect the active organization
The dashboard URL shape is usually /orgs/acme/dashboard. The challenge: the URL says one organization is active, but the user's session holds the active org. If they are out of sync, data filters silently break.
organizationSyncOptions closes the gap by activating the org in the URL on the server before the page renders. Treat it as a convenience for URL-driven dashboards, not a security boundary — Clerk advises against deriving the active organization (or the tenant identity) from the URL slug alone. The session's orgId remains the source of truth for authorization and data scoping; the sync option only keeps the URL and the session aligned.
organizationSyncOptions for URL-based org activation
Pass patterns to clerkMiddleware() so it parses the URL and sets the active org automatically:
// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server'
const isProtectedRoute = createRouteMatcher(['/orgs/(.*)', '/me(.*)'])
export const onRequest = clerkMiddleware(
(auth, context) => {
const { isAuthenticated, redirectToSignIn } = auth()
if (isProtectedRoute(context.request) && !isAuthenticated) {
return redirectToSignIn()
}
},
{
organizationSyncOptions: {
organizationPatterns: ['/orgs/:slug', '/orgs/:slug/(.*)'],
personalAccountPatterns: ['/me', '/me/(.*)'],
},
},
)Pattern syntax is Express-style: :slug is a named parameter; (.*) catches any trailing segments. When the user hits /orgs/acme/dashboard, Clerk activates the org with slug acme for this request. If the user lacks access, Clerk handles the error.
Reading the active organization server-side
In the page frontmatter:
---
// src/pages/orgs/[slug]/dashboard.astro
export const prerender = false
import { clerkClient } from '@clerk/astro/server'
const { isAuthenticated, orgId, orgSlug, orgRole } = Astro.locals.auth()
if (!isAuthenticated) return Astro.redirect('/sign-in')
if (!orgId) return Astro.redirect('/me')
// Defense-in-depth: URL slug must match the active org
if (Astro.params.slug !== orgSlug) {
return Astro.redirect(`/orgs/${orgSlug}/dashboard`)
}
const org = await clerkClient(Astro).organizations.getOrganization({
organizationId: orgId,
})
---
<h1>{org.name}</h1>
<p>You are a {orgRole} of this organization.</p>clerkClient(Astro) is the Backend API client. Unlike the Next.js factory (which is async), the Astro factory takes the APIContext (the Astro global in a page, context in an APIRoute) synchronously. Methods on the returned client are async and still need await.
Filtering data by organization
Every database query must scope to locals.auth().orgId. Do not trust Astro.params.slug as the sole source of truth — it is user-controlled input in the URL. The middleware sets the active org based on the URL via organizationSyncOptions, and the auth check above bounces users whose membership does not match. But application code should still do the belt-and-suspenders scoping:
// src/lib/db.ts
export async function listTasks(orgId: string) {
return db.query(
`SELECT id, title, status FROM tasks WHERE organization_id = $1 ORDER BY created_at DESC`,
[orgId],
)
}Call it with locals.auth().orgId, never with Astro.params.slug.
Keeping the switcher in sync
The <OrganizationSwitcher /> needs to navigate to URLs that match organizationSyncOptions:
<OrganizationSwitcher
afterSelectOrganizationUrl="/orgs/:slug/dashboard"
afterCreateOrganizationUrl="/orgs/:slug/dashboard"
afterLeaveOrganizationUrl="/me"
/>When the user switches orgs, Clerk updates the active org on the server (via the switcher's internal API call) and then redirects to the URL shape. The middleware's organizationSyncOptions reconcile the two on the next page render.
Inviting and managing members
Server-side invitation from an Astro APIRoute:
// src/pages/api/invite.ts
import type { APIRoute } from 'astro'
import { clerkClient } from '@clerk/astro/server'
export const prerender = false
export const POST: APIRoute = async (context) => {
const { isAuthenticated, orgId, userId, has } = context.locals.auth()
if (!isAuthenticated) return new Response(null, { status: 401 })
if (!orgId) return new Response('No active org', { status: 400 })
if (!has({ role: 'org:admin' })) {
return new Response('Forbidden', { status: 403 })
}
const { emailAddress, role } = await context.request.json()
const invitation = await clerkClient(context).organizations.createOrganizationInvitation({
organizationId: orgId,
emailAddress,
role,
inviterUserId: userId ?? undefined,
})
return new Response(JSON.stringify(invitation), { status: 200 })
}Inviting members is governed by the built-in org:sys_memberships:manage system permission. System permissions are not carried in the session token, so the route authorizes on the admin role (has({ role: 'org:admin' })) — a has({ permission: 'org:sys_memberships:manage' }) check would always return false. The RBAC section below covers this rule in full.
The required parameters are organizationId, emailAddress, and role. inviterUserId is optional but recommended — it records who sent the invitation for the audit log. When omitted, the invitation is recorded without an inviter (useful for system-initiated invites). Other optional parameters: expiresInDays, redirectUrl, privateMetadata, publicMetadata.
Client-side invitation from a React island:
// src/components/InviteForm.tsx
import { useStore } from '@nanostores/react'
import { $organizationStore } from '@clerk/astro/client'
import { useState } from 'react'
export default function InviteForm() {
const organization = useStore($organizationStore)
const [email, setEmail] = useState('')
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!organization) return
await organization.inviteMember({ emailAddress: email, role: 'org:member' })
setEmail('')
}
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<button type="submit">Invite</button>
</form>
)
}Rate limits to remember:
- Organization invitations: 250/hour single, 50/hour bulk.
- Application-level invitations (user-to-app, not org): 100/hour single, 25/hour bulk.
The dashboard example above uses organization invitations.
Accepting an invitation walks new users through sign-up and existing users through a confirmation step on next sign-in. The experience is built into Clerk — no custom acceptance pages needed.
Role-based access control (RBAC) in Astro
RBAC in Clerk is organization-scoped. A user has one role per organization; roles carry permissions; code checks roles or permissions before rendering or mutating.
Clerk's roles and permissions model
The moving pieces:
- Roles.
org:adminandorg:memberare built-in and available on every plan.org:admincarries every system permission (full management of the organization and its members);org:memberis read-only by default. Defining your own custom roles (formatorg:role_name) requires the B2B Authentication add-on. - System permissions.
org:sys_*(e.g.org:sys_memberships:manage,org:sys_billing:manage) are built-in permissions attached to roles. They are not carried in the session token, sohas({ permission: 'org:sys_*' })always returnsfalse— check the role instead. - Custom permissions.
org:feature:actionformat (e.g.org:reports:export). You can attach these to the built-in roles on any plan; they are carried in the session token and can be checked on the server or the client withhas({ permission }). - Role Sets. A Role Set is one role configuration. The base tier ships the built-in roles in a single Role Set. Custom roles and additional Role Sets require the B2B Authentication add-on.
Admin vs member permissions
By default, org:admin holds every system permission — managing the organization, inviting and removing members, managing verified domains, and managing billing (when enabled). org:member is limited to reading members and billing; what members can do with your application's own data is governed by your app's logic, not by Clerk. Both roles live in the Primary Role Set, configured in the Clerk Dashboard under Organizations → Settings → Roles & Permissions. You can modify the Primary Role Set on any plan — for example, attaching a custom permission to org:member — but creating additional roles or Role Sets requires the B2B Authentication add-on.
Gating UI by role
Three ways to gate, each with different trade-offs.
<Show /> at the component level (client-safe, but DOM is still in the HTML):
---
import { Show } from '@clerk/astro/components'
---
<nav>
<a href="/dashboard">Dashboard</a>
<Show when={{ role: 'org:admin' }}>
<a href="/admin">Admin</a>
<a href="/billing">Billing</a>
</Show>
</nav>Managing billing is governed by a system permission (org:sys_billing:manage), so gate it by the admin role rather than a permission check.
Function predicate for logic the object form cannot express — for example, showing a notice to everyone who is not an admin:
<Show when={(has) => !has({ role: 'org:admin' })}>
<p>Only organization admins can manage billing and members.</p>
</Show>Server-side gating in .astro frontmatter (element omitted from HTML entirely):
---
// src/components/AdminNav.astro
const { has } = Astro.locals.auth()
const isAdmin = has({ role: 'org:admin' })
---
{
isAdmin && (
<section>
<h2>Admin controls</h2>
<a href="/admin/users">Manage users</a>
</section>
)
}Enforcing permissions in API routes
API routes are where authorization really lives. Gate on the caller's role before writing to the database:
// src/pages/api/posts.ts
import type { APIRoute } from 'astro'
export const prerender = false
export const POST: APIRoute = async (context) => {
const { has, orgId } = context.locals.auth()
if (!orgId) return new Response('No active org', { status: 400 })
if (!has({ role: 'org:admin' })) {
return new Response('Forbidden', { status: 403 })
}
const body = await context.request.json()
const post = await db.insert('posts', {
title: body.title,
organization_id: orgId,
status: 'published',
})
return new Response(JSON.stringify(post), { status: 201 })
}This gates publishing on the admin role. If you need finer granularity than admin-versus-member — say, letting some members publish but not others — define a custom permission such as org:posts:publish, attach it to a role, and check has({ permission: 'org:posts:publish' }) instead. Custom permissions ride in the session token and behave identically on the server and the client. You can attach them to the built-in roles on any plan; creating additional custom roles to organize them requires the B2B Authentication add-on.
For route-level enforcement from middleware, read the auth object and return a response yourself. @clerk/astro has no auth.protect() helper — that is a Next.js-only API. The auth parameter is a function: call auth() to get isAuthenticated, has, and redirectToSignIn.
// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server'
const isAdminRoute = createRouteMatcher(['/admin(.*)', '/api/admin(.*)'])
export const onRequest = clerkMiddleware((auth, context) => {
if (!isAdminRoute(context.request)) return
const { isAuthenticated, has, redirectToSignIn } = auth()
if (!isAuthenticated) return redirectToSignIn()
if (!has({ role: 'org:admin' })) {
return new Response('Forbidden', { status: 403 })
}
})Whichever layer you enforce in, also confirm the request is scoped to the expected org. has({ role }) implicitly requires an active organization, but your data-layer query still needs to filter on orgId (covered next).
Middleware path-matching is convenient, but Clerk recommends enforcing authorization closest to the resource — in the page, API route, or query — because path patterns can drift from how Astro actually resolves routes. Treat the in-page and in-route checks above as the primary line of defense and middleware as a coarse first pass.
Organization-specific data filtering
Every query that reads tenant data must filter on locals.auth().orgId. Do not let clients pass organizationId in a request body or URL parameter and then use it directly — always derive it from the session.
A safer pattern using a thin repository:
// src/lib/tasks.ts
import { db } from './db'
export async function listTasksForOrg(orgId: string) {
return db.query(
`SELECT id, title, status FROM tasks WHERE organization_id = $1 ORDER BY created_at DESC`,
[orgId],
)
}
export async function createTaskForOrg(orgId: string, input: { title: string }) {
return db.query(`INSERT INTO tasks (organization_id, title) VALUES ($1, $2) RETURNING *`, [
orgId,
input.title,
])
}Call sites pass locals.auth().orgId. For Postgres, consider database-level row-level security (RLS) as defense-in-depth: map the Clerk session claim to a Postgres role and write USING (organization_id = current_setting('app.current_org')) policies.
Conclusion
Session management and organization boundaries are critical components of any B2B application. By using Clerk's robust session handling and the Organizations primitive, you can implement secure multi-tenant dashboards without rolling your own tenant schema. Pairing organizationSyncOptions with Astro middleware keeps your dashboard URLs aligned with the user's active organization — while the session's orgId, not the URL slug, stays the source of truth for authorization and data scoping. Role-based access control then provides granular security at every layer.
In the final part of this series, we will cover advanced security by protecting API routes, provide a detailed comparison of authentication providers, and review common pitfalls and best practices.
Frequently asked questions
In this series
- Authentication for Astro Sites
- Authentication for Astro Sites - Part 2
- Authentication for Astro Sites - Part 3 (you are here)
- Authentication for Astro Sites - Part 4