bext.dev
DocsServer-Side Includes
Server-Side Includes4 min read
On this page

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.

html
<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".

Tip

The auth gate runs before the resolver and before any cache lookup. For anonymous users hitting an auth="required" include, the include is rejected at zero cost — no resolver is invoked and no cache key is computed.

Session context#

Auth gates read from the request's SessionInfo:

rust
pub struct SessionInfo {
    pub authenticated: bool,
    pub user_id: Option<String>,
    pub email: Option<String>,
    pub roles: Vec<String>,
    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#

html
<!-- 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#

html
<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#

html
<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).

html
<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:

html
<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.

Warning

Always add vary="role" (or vary="auth") to any auth-gated include that also uses ttl. Without it, the first user's resolved content is cached and served to everyone — including users who should not see it. bext logs a warning; strict mode disables caching entirely for such includes.

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:

html
<!-- 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.

code
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:

html
<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:

code
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:

rust
impl IncludeResolver for MyCustomResolver {
    fn resolve(&self, d: &IncludeDirective, ctx: &IncludeContext) -> Option<String> {
        // 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#

Edit this page ↗Need a hand? ↗
FIND YOUR NEXT STEP

Start with a topic, a command, or a question.