Protecting Astro server islands

Updated

You protect a dashboard route with middleware, check the user’s access to the organization, and defer a slow chart:

<RevenueChart server:defer />

The chart still looks like part of the dashboard, but it now arrives through another request. That request needs its own authorization.

Follow the second request

Astro’s server islands fetch a component’s HTML through a generated endpoint:

GET /dashboard/acme
  → page authorization
  → page and chart placeholder

GET /_server-islands/RevenueChart?...
  → island authorization needed here too
  → chart HTML

Middleware runs for both requests, but a guard matching only /dashboard/* misses the island endpoint. We encountered a similar mismatch when our guard expected source-file paths while Astro’s generated endpoints used component names. Check the actual routes your build produces.

Authorize the resource inside the island

Pass the organization ID from the authorized page into the island:

<RevenueChart
  server:defer
  organizationId={organization.id}
/>

Astro encrypts these props, but a previously valid payload can be sent again. Encryption does not prove that the current caller still has access. The island must check before querying private data:

---
import { requireOrganizationAccess } from "../auth";
import { loadRevenue } from "../data";

interface Props {
  organizationId: string;
}

const organization = await requireOrganizationAccess(
  Astro,
  Astro.props.organizationId,
);

const revenue = await loadRevenue(organization.id);
---

<p>Revenue: {revenue.formattedTotal}</p>

requireOrganizationAccess is an illustrative application helper, not an Astro API. It validates the ID, authenticates the current request, checks current permissions, and stops execution on denial. In our implementation, it throws an access error that island middleware turns into a 401 or 403 before streaming starts.

Use the resource ID from the props, never the Referer header, to select the organization. A caller-controlled header must not turn a request for organization A into a query for organization B.

For private fragments, we also set Cache-Control: private, no-store, including on denied responses.