Auth-gated includes

Most pages have fragments that only make sense for certain users: admin toolbars, premium-content teasers, sign-in CTAs, user profile dropdowns. Client-side gating leaks the markup to the DOM and causes layout shift. Auth-gated includes resolve the right content server-side, so the response never contains fragments the user shouldn't see.

<bext-include src="/partials/admin-toolbar"
  auth="required" role="admin"
  vary="role" ttl="30m"
>
  <!-- optional fallback for non-admins -->
</bext-include>

Authenticated admins see the toolbar; everyone else sees the fallback (or nothing).

Attributes

Attribute Values Purpose
auth required Only resolve for authenticated users
auth anonymous Only resolve for non-authenticated users
role CSV of role names Only resolve if user has one of these roles

Use auth="required" alone for any signed-in user. Add role="admin" to require a specific role. List multiple roles with commas for "any match".

Session context

Auth gates read from the request's SessionInfo:

pub struct SessionInfo {
    pub authenticated: bool,
    pub user_id: Option,
    pub email: Option,
    pub roles: Vec,
    pub is_super_admin: bool,
}

This is populated once per request by bext's auth middleware — either from a local session (SQLite-backed) or a WorkOS sealed session cookie. The include system doesn't care which — it reads the SessionInfo and makes decisions.

Common patterns

Sign-in vs user dropdown

<!-- Anonymous: show sign-in button -->
<bext-include src="/partials/signin-button"
  auth="anonymous"
  vary="auth"
></bext-include>

<!-- Authenticated: show user dropdown -->
<bext-include src="/partials/user-dropdown"
  auth="required"
  vary="auth"
></bext-include>

Both can cache safely because they declare vary="auth" — each cache entry is specific to the auth state.

Role-specific content

<bext-include src="/partials/admin-nav"
  auth="required" role="admin,superadmin"
  vary="role"
></bext-include>

Any role from the CSV list counts. Super admins bypass role checks automatically (they have access to everything).

Premium content teasers

<bext-include src="/partials/premium-content"
  auth="required" role="paid"
>
  <div class="upgrade-prompt">
    <p>This is premium content — <a href="/upgrade">upgrade to see</a>.</p>
  </div>
</bext-include>

Paying users get the content. Non-paying users see the upgrade prompt (the fallback between open and close tags).

Hide admin links from anonymous users

<nav>
  <a href="/home">Home</a>
  <bext-include src="/partials/admin-link"
    auth="required" role="admin"
  >
    <!-- empty fallback — admin link simply omitted -->
  </bext-include>
</nav>

Fallback content

The content between <bext-include> and </bext-include> is the fallback rendered when the auth gate rejects:

<bext-include src="/partials/members-only" auth="required">
  <p>Please <a href="/login">sign in</a> to continue reading.</p>
</bext-include>

- Authenticated user: sees the resolved members-only content.

- Anonymous user: sees the "Please sign in" message.

- Missing fallback: emits an empty string (the element disappears).

Fallback also works with SSI's stub attribute and ESI's alt — see SSI & ESI compatibility.

Cache poisoning guard

Auth-gated includes that are cached must vary on the relevant dimension. Otherwise, the cache would serve admin content to non-admins:

<!-- BAD: first admin request caches admin content; then served to anyone -->
<bext-include src="/partials/admin-toolbar"
  auth="required" role="admin"
  ttl="30m"
></bext-include>

<!-- GOOD: separate cache entry per role -->
<bext-include src="/partials/admin-toolbar"
  auth="required" role="admin"
  ttl="30m" vary="role"
></bext-include>

bext logs a warning when an auth-gated include is cached without vary="role" or vary="auth". In strict mode (configurable), caching is disabled for such includes instead of serving potentially incorrect content.

WARN auth-gated include /partials/admin-toolbar is cacheable but doesn't
     vary on role — cache poisoning risk

Super admins

Users with is_super_admin=true bypass all role checks:

<bext-include src="/partials/staff-tools"
  auth="required" role="editor,moderator"
></bext-include>

A super admin sees this include even without the editor or moderator role. Super admin status is set by SUPER_ADMIN_EMAILS environment variable (comma-separated emails) and takes effect through SessionInfo::has_any_role.

Performance

The auth gate runs before resolution and before cache lookups. For anonymous users hitting an auth="required" include:

Request → parse directive → auth gate REJECTS → return fallback

Zero resolution cost. Zero cache key computation. Zero resolver invocation. The cheapest possible path for the common case of "most users don't see this."

Integration with custom resolvers

Plugin resolvers receive the session via the include context:

impl IncludeResolver for MyCustomResolver {
    fn resolve(&self, d: &IncludeDirective, ctx: &IncludeContext) -> Option {
        // Auth gate has already approved — we know the user qualifies.
        let user = ctx.session.as_ref()?;
        Some(render_personalized(&user.email))
    }
}

Custom resolvers don't need to re-check auth; the AuthGateResolver has already filtered requests before the chain reaches them.

See also

- Vary-aware cachingvary="role" to cache per role

- Experiments — combining auth with A/B tests

- Capabilities: auth — bext's auth plugin system

- Capabilities: session — session storage options