Guides
Build guide

BuildBase on Astro: one middleware for sign-in, roles and API tokens

Astro middleware authentication with roles: one onRequest gate where admins and editors write, viewers read, strangers bounce and machines use a token.

Dharmendra Jagodana5 min read

In short

Put Astro authentication in one src/middleware.ts. It reads the BuildBase session, looks up the person's role in one workspace, and hands both to a pure decide() function you can unit-test. Admins and editors write, viewers only read, everyone else is sent away, and /api still accepts a token for machines.

Signing in is the easy half of Astro authentication. The tutorials we read got you to "we know who this is" and stopped there, one of them at a boolean: locals.isAdmin. Whether your admin leaks depends on the next question: may this request do this?

Here is what happens when nobody asks it. Cloudflare's SaaS Admin Template, an Astro dashboard for customers and subscriptions, ships with /admin open to anyone who has the URL. Its API is guarded by a single API_TOKEN, and the admin's own Create dialogs work by handing that token to the browser:

<CreateCustomerButton apiToken={API_TOKEN} client:only="react" />

A client:only component's props are serialized into the page. So the one secret protecting the API is in the HTML of every admin page.

We fixed that template with one middleware and one pure function. The example runs on Cloudflare Workers with D1. Admins and editors write. Viewers only read, strangers are sent away, and scripts keep using the token - on /api only.

Before you start

An Astro app with server output (the example uses the Cloudflare adapter), a BuildBase organization, and an auth client created in the console under User Management → Authentication, with its client ID and secret. Register http://localhost:4321/auth/callback on the client.

Tip

Start from the working example. astro-saas-admin in the examples repo runs everything on this page in the SaaS Admin Template. Watch it working, or copy it with npx degit buildbase-app/examples/astro-saas-admin my-app.

1. Write the rule first, as a pure function

// src/lib/buildbase.ts
export type Role = 'admin' | 'editor' | 'viewer';
export const canWrite = (role: Role | null) =>
  role === 'admin' || role === 'editor';

export function decide({
  pathname,
  method,
  apiToken,
  member,
}: {
  pathname: string;
  method: string;
  apiToken: boolean; // the request carried a valid API_TOKEN
  member: { role: Role | null } | null; // null: not signed in
}) {
  const isApi = pathname.startsWith('/api/');
  const isAdmin = pathname === '/admin' || pathname.startsWith('/admin/');
  if (!isApi && !isAdmin) return { allow: true };
  if (isApi && apiToken) return { allow: true }; // machines, as before
  if (!member) return { allow: false, status: 401, reason: 'sign-in' };
  if (!member.role)
    return { allow: false, status: 403, reason: 'not-a-member' };
  if (isApi && method !== 'GET' && !canWrite(member.role)) {
    return { allow: false, status: 403, reason: 'read-only' };
  }
  return { allow: true };
}

Why pure? Because this is the part you most want tests on, and a function with no I/O runs under node --test with no server and no mocks. The example has seven: the landing page stays open, signed-out visitors go to sign-in, strangers are refused, admins and editors can write, viewers read but cannot write, the token works on /api and only there, and /administrator is not mistaken for /admin.

The roles are BuildBase's workspace roles: admin, editor and viewer. The admin belongs to one BuildBase workspace, named by ADMIN_WORKSPACE_ID, and your place in that workspace is your place in the admin.

2. The middleware gathers the inputs and applies the rule

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';

export const onRequest = defineMiddleware(async (context, next) => {
  const env = context.locals.runtime.env;
  const { pathname } = context.url;
  const sessionId = context.cookies.get('bb_session')?.value;

  context.locals.member = sessionId
    ? await buildbase(env).member(sessionId)
    : null;
  if (sessionId && !context.locals.member)
    context.cookies.delete('bb_session', { path: '/' });

  const apiToken =
    pathname.startsWith('/api/') &&
    (await validateApiToken(context.request, env.API_TOKEN));

  const verdict = decide({
    pathname,
    method: context.request.method,
    apiToken,
    member: context.locals.member,
  });
  if (verdict.allow) return next();

  if (pathname.startsWith('/api/')) {
    return Response.json(
      { message: verdict.reason },
      { status: verdict.status }
    );
  }
  return verdict.reason === 'sign-in'
    ? context.redirect(`/auth/sign-in?next=${encodeURIComponent(pathname)}`)
    : context.redirect('/access');
});

member() makes the BuildBase calls: the profile and the workspace list together, as the Hono guide does on Workers, then the members of the admin workspace, from which it reads the role. A session BuildBase no longer knows comes back as null, and the cookie is cleared. Pages read Astro.locals.member for the header's role badge, and hide the Create buttons from viewers:

---
const writable = canWrite(Astro.locals.member?.role ?? null);
---
{writable && <CreateCustomerButton client:only="react" />}

No apiToken prop any more. The dialogs call /api/customers with the session cookie, and the middleware checks it like any other request. Hiding the button is courtesy; the 403 from decide() is the rule.

3. Sign-in: three small endpoints

/auth/sign-in stores a random state (and where to go next, same-site paths only) in a short-lived cookie, then redirects to the URL from AuthApi.requestAuth(). /auth/callback checks the state, exchanges the code for a BuildBase session with the client secret, and sets bb_session as an httpOnly, SameSite=Lax cookie.

/auth/sign-out is a POST, and it ends the BuildBase session too. The cookie holds nothing but the session ID. The middleware checks that ID with BuildBase on every request, so a made-up value is refused like any session BuildBase does not know.

Open /admin signed out and you go through the hosted page (whichever of the eight sign-in methods your org has on) and come back to /admin, with your role in the header.

4. The first run: pick the workspace

With ADMIN_WORKSPACE_ID unset, a signed-in visitor lands on /access, which lists their BuildBase workspaces and IDs. By default BuildBase creates one for each person on first sign-in, and they are its admin. Put that ID in ADMIN_WORKSPACE_ID, restart, and invite teammates into that workspace as editors or viewers. If one of them cannot see the workspace after accepting, the invite is stuck in one of a few states. Because the middleware reads the role on every request, a role change or a removal takes effect on the teammate's next click.

Key takeaway

In Astro, authorization is one middleware and one pure function. The middleware gathers who and what; decide() says yes or no, and it is the part with the tests.

CSRF: what Astro covers, and what it does not

security.checkOrigin is on by default since Astro 4.9. It refuses cross-origin POST, PUT, PATCH and DELETE requests sent with a form content type or none at all, so a cross-site sign-out, or a hidden form posting to your admin, gets a 403. We checked: a sign-out posted with a foreign Origin header was refused.

It does not look at JSON requests. Those are covered differently: another site cannot send Content-Type: application/json cross-origin without a CORS preflight, which your API does not answer, and the SameSite=Lax session cookie is not sent on cross-site subrequests anyway.

Where the upstream template is simpler

Upstream needs no outside service and makes no network calls to decide access. This version asks BuildBase up to three times on each request that carries a session cookie, in two round trips, and needs ADMIN_WORKSPACE_ID set up once. For a busy admin, cache member() for a minute (in KV, say) and accept that a removed teammate keeps access for that minute. And if you only want sign-in with a fixed list of admins, Clerk's Astro integration protects routes from its own middleware with less code than this.

What breaks, and how you can tell

Everyone lands on /access with "not a member". ADMIN_WORKSPACE_ID names a workspace they are not in. Check the ID the /access page listed when the variable was unset.

Viewers get 403 on a button that should work. It is a write. decide() allows viewers GET only, by design; make them editors.

Scripts get 401 on /api. They are not sending the token, or not on /api. The token is accepted as Authorization: Bearer, Authorization: Token or x-api-token, and never on /admin pages.

Scripts get 403 on a POST, before the token is checked. Astro's origin check treats a request with no Origin header as cross-site when it is sent as a form or with no content type. Send Content-Type: application/json.

The hosted page refuses to send you back. The callback URL must match one registered on the auth client exactly, path included; the port must match too, except on localhost, where it is ignored. A wildcard can stand in for one leftmost subdomain on a domain you own, but not for a public suffix like *.workers.dev.

Install

Add the SDK to your own app, or start from the example with npx degit buildbase-app/examples/astro-saas-admin my-app.

npm i @buildbase/sdk
astro
cloudflare-workers
rbac

Frequently Asked Questions

Should I protect Astro routes in middleware or in each page?

In middleware. It runs before every page and every endpoint, so a page someone forgets to guard cannot leak data, and pages read the result from Astro.locals instead of repeating the check.

How do I let scripts call protected Astro API routes?

Accept a token on /api alongside the session cookie, and keep the token on the server. Never pass it to a React island as a prop: anything a client:only component receives ends up in the browser.

Does Astro protect against CSRF?

For form posts, yes. security.checkOrigin, on by default since Astro 4.9, refuses cross-origin POST, PUT, PATCH and DELETE requests sent as forms or with no content type. It does not inspect JSON requests; those rely on the CORS preflight and a SameSite=Lax cookie.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime