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