# Authentication for Astro Sites - Part 3

> Part 3 of 4. Start with [Authentication for Astro Sites](https://clerk.com/articles/authentication-for-astro-sites.md).

> This is Part 3 of a four-part series on adding authentication to Astro sites. This part dives into the core mechanics: session management, working with organizations for multi-tenant dashboards, and role-based access control (RBAC).

## 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](https://clerk.com/docs/guides/how-clerk-works/overview.md) for the full picture.

> `__session` is not marked HttpOnly because the frontend SDK needs JavaScript access to attach it to outgoing requests. The 60-second TTL and 50-second refresh interval mitigate the [XSS](https://clerk.com/glossary.md#cross-site-scripting-xss) risk by limiting the window a stolen token is usable. Keeping your app free of XSS vulnerabilities is still the first line of defense.

### 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:

```astro
---
// 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:

```astro
---
// 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.

> `user.privateMetadata` is only available on the server-side `Backend User` object (the one returned by `currentUser()` or `clerkClient().users.getUser()`). It should never reach client JavaScript. Rendering it inline in `.astro` HTML leaks it to every visitor.

### Reading the current user on the client

Client-side access comes from the `$userStore` nanostore. It updates reactively and works in any framework island:

```tsx
// 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:

```ts
// 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):

```ts
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](https://clerk.com/docs/guides/secure/session-options.md) 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.com` auto-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](https://dashboard.clerk.com/~/organizations-settings), 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:admin` and `org:member` roles.
- 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.

> Organizations work on the free Hobby plan: the `org:admin` and `org:member` roles, custom permissions, member invitations, up to 20 members per organization, and 100 production MROs are all included at no cost. The B2B Authentication add-on is what adds custom roles, unlimited members, verified domains, and per-organization enterprise SSO.

### 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:

```astro
---
// 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.
- `afterSelectOrganizationUrl` takes a template string with `:slug`, which Clerk substitutes with the selected org's slug. Pair this with `organizationSyncOptions` below.
- `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:

```astro
---
// 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:

```ts
// 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:

```astro
---
// 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:

```ts
// 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`.

> Using `Astro.params.slug` for data isolation is a common tenant-leakage bug. A user with membership in `acme` who visits `/orgs/evil-corp/dashboard` could see evil-corp's data if the app queries by the URL slug. Always derive the tenant ID from the session (`orgId`).

#### Keeping the switcher in sync

The `<OrganizationSwitcher />` needs to navigate to URLs that match `organizationSyncOptions`:

```astro
<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.

> `organizationSyncOptions` activates the URL's org server-side. The client-side `<OrganizationSwitcher />` still needs `afterSelectOrganizationUrl` to match the URL shape. Missing the URL template breaks the "switch and stay on the same page" flow.

### Inviting and managing members

Server-side invitation from an Astro APIRoute:

```ts
// 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:

```tsx
// 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](https://clerk.com/docs/guides/how-clerk-works/system-limits.md).
- [Application-level invitations (user-to-app, not org): 100/hour single, 25/hour bulk](https://clerk.com/docs/guides/how-clerk-works/system-limits.md).

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.

> For fetching the current user, prefer `Astro.locals.currentUser()` over `clerkClient(Astro).users.getUser(userId)`. `currentUser()` deduplicates per request and does not count against your Backend API quota on repeated calls within the same render. Use `clerkClient` when you need users or organizations other than the active session.

## Role-based access control (RBAC) in Astro

[RBAC](https://clerk.com/glossary.md#role-based-access-control-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:admin` and `org:member` are built-in and available on every plan. `org:admin` carries every system permission (full management of the organization and its members); `org:member` is read-only by default. Defining your own **custom roles** (format `org: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, so `has({ permission: 'org:sys_*' })` always returns `false` — check the **role** instead.
- **Custom permissions**. `org:feature:action` format (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 with `has({ 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.

> `has({ permission: "org:sys_*" })` on the server returns false for system permissions because they are not in the session token. Use `has({ role: "org:admin" })` instead for system-level actions. Custom permissions (`org:feature:action`) are in the token and work as expected.

### 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](https://dashboard.clerk.com/~/organizations-settings/roles) 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):

```astro
---
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:

```astro
<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):

```astro
---
// 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>
  )
}
```

> `<Show />` only hides content at render time. The HTML is still present in the page source. Browser inspectors can see it. For truly sensitive data (financial details, secrets, PII), gate it server-side in frontmatter and do not render it at all. The best gate is the one that runs closest to the data — the API route or database query.

### Enforcing permissions in API routes

API routes are where authorization really lives. Gate on the caller's role before writing to the database:

```ts
// 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`.

```ts
// 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:

```ts
// 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

## FAQ

### Can I build a multi-tenant dashboard with Clerk and Astro?

Yes. Enable Organizations, add `<OrganizationSwitcher />` and `<OrganizationProfile />`, and pass `organizationSyncOptions` to `clerkMiddleware()` so the URL's org slug activates the matching org server-side. Scope every query to `locals.auth().orgId` — never trust `Astro.params.slug` as the tenant identity. Organizations are included on the free Hobby plan (up to 20 members per org and 100 production MROs).

### How do I implement role-based access control in an Astro app?

Enable Organizations and use the built-in `org:admin` and `org:member` roles. Check server-side with `has({ role: "org:admin" })`, gate UI with `<Show when={{ role: "org:admin" }}>`, and enforce the role in API routes before writing. System permissions (`org:sys_*`, such as managing members) are not in the session token, so authorize those actions by role. Custom permissions (`org:feature:action`) are in the token and work with `has({ permission })`; custom roles and additional Role Sets require the B2B Authentication add-on.

## In this series

1. [Authentication for Astro Sites](https://clerk.com/articles/authentication-for-astro-sites.md)
2. [Authentication for Astro Sites - Part 2](https://clerk.com/articles/authentication-for-astro-sites-2.md)
3. **Authentication for Astro Sites - Part 3** (you are here)
4. [Authentication for Astro Sites - Part 4](https://clerk.com/articles/authentication-for-astro-sites-4.md)
