Skip to Content
AuthorizationAstro SSR

Astro SSR Integration

@evanion/acl 0.1.0 and its patch releases up to 0.1.2, the version on npm. Values on this page were produced by running the source at 40b8f84, a later commit than the 0.1.0 tag, and are not re-executed.

In an Astro application using Server-Side Rendering (SSR), the boundary between the user’s request and the server’s response is the page’s frontmatter. Because this code runs on the server for every HTTP method at its URL, a single ACL check can gate both the page render and any form submissions posting back to it.

In the Baize storefront (apps/storefront), @evanion/acl is used to ensure that users can only interact with the shopping cart if they have the required permissions.

Single-Point Authorization in Astro

In frameworks like Next.js or React Router, a page render and a data write often happen through two different entry points (e.g., a Page component and a Server Action). In Astro, both a GET request to view a page and a POST request to submit a form reach the same frontmatter.

In apps/storefront, the /cart page handles both. By calling access.can at the very top of the frontmatter, the application resolves the permission once and uses the verdict for both the UI and the enforcement.

const decision = decide(access, subject, 'order', 'create'); const mayOrder = decision?.allowed === true; if (Astro.request.method === 'POST' && !mayOrder) { Astro.response.status = 403; refusal = refusalText(decision); }

If a POST request is refused, the application returns a 403 Forbidden response. The refusalText is derived from the ACL decision’s reason, providing the user with a clear explanation of why the action was blocked.

Syncing the UI with the Enforcement

Because the same decision governs both the render and the write, it is impossible for the page to display a control that its own handler would refuse.

The checkout button in cart.astro is enabled or disabled based on the mayOrder variable, which is the result of the order.create check performed in the frontmatter:

<form method="post" action="/cart"> <input type="hidden" name="intent" value="checkout" /> <button class="baize-button baize-button--variant-primary" disabled={!mayOrder} > {mayOrder ? 'Place the order' : 'Sign in to order'} </button> </form>

While the disabled button provides a better user experience, the actual enforcement happens in the POST branch of the frontmatter. A direct script posting to /cart with intent=checkout will still be blocked by the same ACL check, regardless of the UI state.

The unified authorization flow in Astro. Both the page view and a direct POST enter the same frontmatter, which resolves the permission once. This single verdict is then used to both render the UI and enforce the write path.

This is verified by tests that attempt to post as an anonymous visitor and confirm that no order is created in the backend API.

it('refuses an anonymous visitor and places no order', async () => { const calls = shopApiStub(); const response = await checkout(ANONYMOUS_SUBJECT); const html = await response.text(); expect(response.status).toBe(403); expect(html).toContain('data-refused="order.create"'); expect(calls.some((call) => call.method === 'POST')).toBe(false); });

Establishing the Server Boundary

For this pattern to work, the Astro app must be configured for Server-Side Rendering. In a prerendered (static) build, the frontmatter runs only once at build time, meaning there is no request-time context to resolve a subject against.

The apps/storefront/astro.config.mjs file sets output: 'server' and uses the @astrojs/node adapter. This ensures the frontmatter runs on every request inside a Node.js process controlled by the operator.

Resolving the Subject and Policy in Middleware

To avoid repeating setup logic on every page, the storefront uses Astro middleware to resolve the subject and the ACL document before the frontmatter ever executes.

The middleware reads the visitor’s identity from a cookie and fetches the latest policy document from apps/shop-api. The resulting access evaluator and subject are then attached to Astro.locals, making them available to every page.

export const onRequest = defineMiddleware(async (context, next) => { const subject = readSubject(context.cookies); context.locals.subject = subject; context.locals.api = ShopApi.forRequest(context.request, subject); context.locals.cart = readCart(context.cookies); context.locals.access = await policyAccess(context.locals.api); const features = await featureStore(context.locals.api); context.locals.features = features.for({ targetingKey: subject.id }); const response = await next(); // Echoed on the way out so the id is readable with `curl -I` as well as in // the page, which is what makes the hop observable without a browser. response.headers.set(CORRELATION_HEADER, context.locals.api.correlationId); return response; });

The policyAccess evaluator caches the document and only refreshes it when the revalidation window closes. This ensures that ACL decisions in the frontmatter are local function calls rather than network requests.

Sanitizing Roles at the App Boundary

The storefront acts as a bridge between the user and the shop-api. While the API trusts the subject it is given, the storefront is the last point where the application can verify which roles it is asserting on behalf of the user.

To prevent a user from manually editing their cookie to claim a manager role, the parseSubject function screens the subject, discarding any roles not included in STOREFRONT_ROLES. If an invalid role is claimed, the subject is downgraded to an anonymous shopper.

const STOREFRONT_ROLES: readonly string[] = ['customer']; /** * A browser that has not signed in. * * It states no role, so the shop-api's `order.create` rule -- which requires * `customer` -- refuses it, both here and again at the service. A shopper signs * in at `/session` to get {@link CUSTOMER_SUBJECT}. */ export const ANONYMOUS_SUBJECT: StorefrontSubject = { id: 'anonymous', roles: [], shop: '', }; /** The one identity `/session` issues: a signed-in shopper. */ export const CUSTOMER_SUBJECT: StorefrontSubject = { id: 'customer:demo', roles: ['customer'], shop: '', }; /** * The subject a cookie payload states, or {@link ANONYMOUS_SUBJECT}. * * A payload missing a member, stating one at the wrong type, or claiming a role * outside {@link STOREFRONT_ROLES} is discarded whole. Repairing half of it * would hand the matrix a subject whose `shop` nobody stated. */ export function parseSubject(value: unknown): StorefrontSubject { if (typeof value !== 'object' || value === null) return ANONYMOUS_SUBJECT; const { id, roles, shop } = value as Record<string, unknown>; if (typeof id !== 'string' || id === '') return ANONYMOUS_SUBJECT; if (typeof shop !== 'string') return ANONYMOUS_SUBJECT; if (!Array.isArray(roles)) return ANONYMOUS_SUBJECT; if (!roles.every((role) => STOREFRONT_ROLES.includes(role as string))) { return ANONYMOUS_SUBJECT; } return { id, roles: [...(roles as string[])], shop }; }

The Backend as the Final Arbiter

Even with a perfectly implemented frontend, the Astro app is not the final authority on security. Because the storefront ships no JavaScript to the browser, all decisions happen in the Node process, but the data still lives in apps/shop-api.

The backend service always re-evaluates the request using the same policy document. This adheres to the principle of complete mediation: a storefront that disables a button does not excuse the service that receives the order.

Where to go next

Last updated on