Skip to main content
Site Architecture

Middleware & Authentication

Request middleware pipeline, route-level auth enforcement, role system, and SEO handling

February 23, 2026

Middleware & Authentication

The Astro middleware at src/middleware.ts intercepts every request before it reaches a page or API route. It enforces authentication on protected routes, resolves user roles, handles hostname validation, injects SEO headers for bots, and provides dev mode shortcuts. The role system defined in src/lib/roles.ts controls what each user can access.

Middleware Pipeline

Every request flows through this pipeline:

Incoming Request
    │
    ▼
1. Hostname Enforcement
    │  (production only -- reject requests to unknown hosts)
    │
    ▼
2. Route Classification
    │  (public, admin, auth, api, user, dashboard)
    │
    ▼
3. Bot/SEO Handling
    │  (noindex/nofollow for admin/api/auth routes)
    │
    ▼
4. Dev Mode Check
    │  (if DEV, inject admin user and continue)
    │
    ▼
5. Authentication Resolution
    │  (CF Access headers → cookie fallback → unauthenticated)
    │
    ▼
6. Role Resolution
    │  (email → KV lookup → UserRecord)
    │
    ▼
7. Permission Check
    │  (does this role have access to this route?)
    │
    ▼
8. Continue to Page/API Route
    │  (user available via Astro.locals.user)
    │
    ▼
Response

Hostname Enforcement

In production, the middleware validates the Host header against an allowlist to prevent the site from being served on unexpected domains:

const ALLOWED_HOSTS = [
  'argobox.com',
  'www.argobox.com',
  // Preview deployment pattern
  /\.argobox\.pages\.dev$/,
];

function isAllowedHost(host: string): boolean {
  return ALLOWED_HOSTS.some(allowed =>
    typeof allowed === 'string'
      ? host === allowed
      : allowed.test(host)
  );
}

Requests from unknown hosts receive a 403 response. The regex pattern allows Cloudflare Pages preview deployments (e.g., abc123.argobox.pages.dev) to pass through.

This check is skipped entirely in dev mode since the host is localhost:4321.

Route Classification

Routes are classified into categories that determine authentication requirements:

function classifyRoute(pathname: string): RouteType {
  if (pathname.startsWith('/admin'))    return 'admin';
  if (pathname.startsWith('/auth'))     return 'auth';
  if (pathname.startsWith('/user'))     return 'user';
  if (pathname.startsWith('/dashboard')) return 'dashboard';
  if (pathname.startsWith('/api/admin')) return 'admin-api';
  if (pathname.startsWith('/api/user')) return 'user-api';
  if (pathname.startsWith('/api/auth')) return 'auth-api';
  if (pathname.startsWith('/api/dashboard')) return 'dashboard-api';
  if (pathname.startsWith('/api/public')) return 'public-api';
  if (pathname.startsWith('/api'))      return 'api';
  return 'public';
}

Route Type Permissions

Route Type Auth Required Minimum Role Notes
public No -- Homepage, blog, docs, etc.
public-api No -- Contact form, public chat
auth No -- Login/logout handlers
auth-api Varies -- /api/auth/me is public, others are admin
admin Yes admin Full admin panel
admin-api Yes admin Admin API endpoints
user Yes member User portal
user-api Yes member User API endpoints
dashboard Yes member Personalized dashboard
dashboard-api Yes member Dashboard data endpoints
api Varies -- General API routes (checked individually)

Dev Mode

When import.meta.env.DEV is true (local development via npm run dev), the middleware bypasses all authentication checks and injects a full admin user:

if (import.meta.env.DEV) {
  context.locals.user = {
    email: 'dev@argobox.com',
    role: 'admin',
    displayName: 'Dev Mode',
    services: ['*'],
    features: ['*'],
    sites: ['*'],
    dashboardProfiles: [],
  };
  return next();
}

This means:

  • Every route is accessible without login
  • All admin features work
  • All API endpoints return data as if called by an admin
  • No Cloudflare Access headers are needed or checked
  • No KV lookups occur

Dev mode is detected by import.meta.env.DEV which Astro sets based on whether you are running npm run dev (true) or npm run build (false).

Authentication Resolution

In production, the middleware extracts the user identity from Cloudflare Access credentials:

async function resolveAuth(request: Request, env: Env): Promise<AuthResult> {
  // 1. Try CF Access JWT header (primary)
  const jwtHeader = request.headers.get('Cf-Access-Jwt-Assertion');
  if (jwtHeader) {
    const identity = await validateAccessJWT(jwtHeader, env);
    if (identity) return { authenticated: true, email: identity.email };
  }

  // 2. Try CF_Authorization cookie (browser fallback)
  const cookies = parseCookies(request.headers.get('Cookie'));
  const authCookie = cookies.get('CF_Authorization');
  if (authCookie) {
    const identity = await validateAccessJWT(authCookie, env);
    if (identity) return { authenticated: true, email: identity.email };
  }

  // 3. Try CF_AppSession cookie (application session)
  const sessionCookie = cookies.get('CF_AppSession');
  if (sessionCookie) {
    const session = await validateSession(sessionCookie, env);
    if (session) return { authenticated: true, email: session.email };
  }

  // 4. Not authenticated
  return { authenticated: false };
}

The fallback chain ensures authentication works regardless of how the request arrives -- direct API calls with headers, browser navigation with cookies, or application sessions.

Network Lock (DISABLED -- 2026-03-03)

The network-lock system has been removed. It was an IP-based trusted network check that ran after authentication resolution and blocked admin mutations (POST/PUT/PATCH/DELETE to /api/admin/*) from untrusted networks. Clients whose IP did not appear on a trusted network allowlist received a 403 even if they had a valid CF Access JWT.

Why it was removed: Mobile devices on carrier networks were blocked from performing admin actions. Cloudflare sees the carrier's IPv6 address, not the Tailscale IP, so phones on cellular data always failed the network check despite being authenticated.

Current auth model: CF Access JWT validation only. Any request with a valid CF Access JWT and sufficient role is permitted regardless of source network.

Planned replacement: A device-based auth layer (mTLS client certificates or Tailscale identity verification) is planned to replace the network-origin check with a device-identity check that works across all network types.

Role System

Role Definitions (src/lib/roles.ts)

The role system defines three tiers with explicit permission sets:

// src/lib/roles.ts
export const roles = {
  admin: {
    permissions: ['*'],
    description: 'Full access to all features and services',
    level: 100,
  },
  member: {
    permissions: [
      'view:dashboard',
      'use:chat',
      'view:status',
      'edit:content',
      'view:analytics',
      'use:playground',
    ],
    description: 'Limited access to specific features',
    level: 50,
  },
  demo: {
    permissions: [
      'view:dashboard',
      'view:status',
    ],
    description: 'Read-only tour of the platform',
    level: 10,
  },
};

Demo Mirror Interceptor

The demo mirror interceptor runs before the isDemoMode() block and works independently of DEMO_MODE. This allows the demo tour to run on production without disabling the real admin area. A fast-path check avoids the dynamic import on normal (non-demo) requests:

hasDemoSignal? (query param / header / referer check)
  → import demo-api → isDemoMirrorRequest()?
    → admin page gate: valid demo session → allow; no session → 401
    → API interceptor: handleDemoApiRequest(pathname, request)
      → POST generators checked first (email actions, etc.)
      → remaining mutations (POST/PUT/PATCH/DELETE) → 403 "view-only"
      → look up generator in registry (20 full generators, 80+ paths) → return synthetic Response
      → no generator → catch-all returns empty JSON (never falls through to real handler)

The interceptor lives in src/lib/demo-api.ts and returns synthetic data from generators in src/lib/demo-data/generators/. Real API handlers never execute for demo mirror traffic. This allows real admin pages to render with fake data when loaded inside the demo shell at /demo/admin/*.

Demo mirror requests are detected via ?demo_mirror=1 query param, x-demo-mirror: 1 header, or referer URL containing demo_mirror=1.

See Demo Mode Tour Security for the full architecture.

Permission Format

Permissions follow a verb:resource convention:

Permission Meaning
* Wildcard -- access to everything
view:dashboard Can view dashboard pages
view:status Can view infrastructure status
use:chat Can use AI chat features
edit:content Can create/edit content
view:analytics Can view traffic analytics
use:playground Can use playground labs

Permission Checking

export function hasPermission(user: UserRecord, permission: string): boolean {
  // Admin wildcard
  if (user.role === 'admin') return true;

  const roleConfig = roles[user.role];
  if (!roleConfig) return false;

  // Check exact match or prefix match
  return roleConfig.permissions.some(p =>
    p === '*' ||
    p === permission ||
    (p.endsWith('*') && permission.startsWith(p.slice(0, -1)))
  );
}

// Usage in API routes
if (!hasPermission(user, 'edit:content')) {
  return new Response('Forbidden', { status: 403 });
}

Role Hierarchy

Roles have a numeric level for comparison:

export function hasMinimumRole(user: UserRecord, minimumRole: string): boolean {
  const userLevel = roles[user.role]?.level ?? 0;
  const requiredLevel = roles[minimumRole]?.level ?? 0;
  return userLevel >= requiredLevel;
}

This allows checks like "must be member or higher" without listing every qualifying role.

SEO Handling

The middleware injects SEO-related headers for search engine bots:

function addSEOHeaders(response: Response, routeType: RouteType): Response {
  const noIndexRoutes: RouteType[] = ['admin', 'admin-api', 'auth', 'auth-api', 'api', 'user', 'user-api'];

  if (noIndexRoutes.includes(routeType)) {
    response.headers.set('X-Robots-Tag', 'noindex, nofollow');
  }

  return response;
}

This ensures search engines never index:

  • Admin pages and their API endpoints
  • Authentication pages
  • User portal pages
  • API endpoints

Public pages, blog posts, docs, and other content pages do not receive this header and are freely indexable.

Preview Deployment Access

For Cloudflare Pages preview deployments (non-production hostnames matching *.argobox.pages.dev), the middleware supports a ?preview=true query parameter that preserves the preview hostname and skips ArgoBox's own host-level redirect/block logic:

function isPreviewAccess(url: URL): boolean {
  const host = url.hostname;
  const isPreview = url.searchParams.get('preview') === 'true';
  const isPreviewDomain = host.endsWith('.argobox.pages.dev');

  return isPreview && isPreviewDomain;
}

When preview mode is detected, the middleware sets a sticky __argobox_preview=1 cookie so subsequent requests on that pages.dev host keep using the preview deployment instead of being canonicalized back to argobox.com.

This does not inject an admin user and does not bypass edge-level Cloudflare Access. If the preview hostname itself is protected by Cloudflare Access, /admin/* and /auth/* can still be intercepted before the middleware runs. In that case, preview admin testing requires an Access policy that explicitly allows the preview host or a separate review-only domain.

That distinction matters operationally: if a preview admin route 302s to Cloudflare Access or a preview /auth/login flow fails before page code loads, that is not evidence of an ArgoBox middleware bug. It means the request likely never reached the middleware in the first place.

Preview mode is strictly limited to *.argobox.pages.dev hostnames. The production domain argobox.com ignores the ?preview=true parameter entirely.

User Context in Pages

After the middleware runs, the authenticated user (if any) is available to all pages and API routes via Astro.locals.user:

---
// In any .astro page
const user = Astro.locals.user;

if (user) {
  // User is authenticated
  console.log(user.email);      // "argo@argobox.com"
  console.log(user.role);       // "admin"
  console.log(user.displayName); // "Commander"
}
---

{user ? (
  <p>Welcome, {user.displayName}</p>
) : (
  <a href="/auth/login">Login</a>
)}

In API routes:

export async function GET({ locals }: APIContext) {
  const user = locals.user;
  if (!user || user.role !== 'admin') {
    return new Response('Forbidden', { status: 403 });
  }
  // Handle admin request
}

The locals.user property is undefined for unauthenticated requests to public routes. For protected routes, if the middleware pipeline completes without returning an error, locals.user is guaranteed to be defined with a valid UserRecord.

middlewareauthenticationrolessecurityseo