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".
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:
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#
<!-- 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-onlycontent. - 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.
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:
<!-- 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<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#
- Vary-aware caching —
vary="role"to cache per role - Experiments — combining auth with A/B tests
- Capabilities: auth — bext's auth plugin system
- Capabilities: session — session storage options