docs/enterprise/auth/secure-by-default.md
New in reflex-enterprise v0.9.1.
Once rxe.AuthPlugin is configured (see the
overview), every non-exempt page, event
handler, base field, and computed var in your app requires a logged-in user
unless you explicitly opt it out.
# Requires `rxe.App` and a configured provider
Secure-by-default only applies when your app uses `rxe.App()` (not `rx.App()`) and `rxe.AuthPlugin` is in `rxe.Config(plugins=[...])` with an OIDC provider configured. See the [overview](/docs/enterprise/auth/overview/).
auth= valueauth= accepts three kinds of value:
auth= value | Meaning |
|---|---|
True | Require an authenticated user. This is the default for every surface. |
False | Public. Allow everyone. |
| a callable check | An authorization check that runs only after authentication succeeds. A truthy result allows; a falsey result or a raised exception denies. |
The four wrappers are exported at top level: rxe.page, rxe.event,
rxe.field, and rxe.var. rxe.event, rxe.field, and rxe.var accept all
three values. rxe.page takes a bool only.
AuthPlugin(auth=...) sets the default applied to every surface that carries no
explicit auth= of its own. It accepts the same three values:
rxe.AuthPlugin(auth=True) # any authenticated user (the default)
rxe.AuthPlugin(auth=False) # open by default
rxe.AuthPlugin(auth=my_org_check) # org-wide authorization check after login
A plain rx.field(...), a bare @rxe.var, a bare @rxe.event, and an
@rxe.page() with no auth= all inherit this global default. An explicit
per-surface rxe.*(auth=...) always overrides it. Changing
AuthPlugin(auth=...) changes the app baseline without editing each field, var,
event, or page.
A callable global default runs on every untagged page load:
# my_app/auth.py
from reflex_enterprise.auth import AuthContext
def is_member_of(ctx: AuthContext) -> bool:
"""Allow only members of the ``admins`` group."""
return "admins" in (ctx.auth_user_state.userinfo.get("groups") or [])
# rxconfig.py
import reflex_enterprise as rxe
from my_app.auth import is_member_of
config = rxe.Config(
app_name="my_app",
plugins=[
rxe.AuthPlugin(
auth=is_member_of,
extra_scopes=["groups"],
)
],
)
Request extra_scopes=["groups"] when checks need the groups claim. See
providers.
Authenticated users who fail the check are sent to /forbidden, not back to
login. Replace that screen with a custom forbidden
page.
Protect a page with @rxe.page. For pages, auth is a bool only. Callable
checks are not supported as a per-page argument, although the global default may
still be callable.
def page(
route: str | None = None,
*,
auth: bool | None = None,
**page_kwargs,
)
auth=None (the default): follow the configured global default
(AuthPlugin(auth=...)).auth=True: always require an authenticated user, regardless of the global
default.auth=False: public page.A protected page injects a guard as the first on_load event. Anonymous
visitors are redirected to the login endpoint, and the requested page is
preserved as a redirect_to query parameter. The post-login flow returns them
there.
redirect_to target is validated against the app's origin.
Only same-origin absolute paths (e.g. /dashboard) or URLs sharing the app's
scheme and host are honored. Any cross-origin, scheme-relative (//evil.test),
or backslash target falls back to the index page.@rxe.page(
route="/dashboard", title="Dashboard"
) # auth=None: follows the global default
def dashboard() -> rx.Component:
"""Protected: anonymous visitors are redirected to /login."""
...
@rxe.page(route="/", title="Home", auth=False)
def index() -> rx.Component:
"""Public landing page (opted out of secure-by-default)."""
...
Any extra **page_kwargs are forwarded to rx.page: title, image,
description, meta, script_tags, and on_load. When on_load is provided,
the guard is prepended.
Because the guard is prepended, a page's on_load runs after authentication.
await User.current() (or ctx.auth_user_state.userinfo) is non-None there:
@rxe.page(route="/dashboard", title="Dashboard", on_load=DashboardState.load)
def dashboard() -> rx.Component: ...
class DashboardState(rx.State):
greeting: str = ""
@rxe.event
async def load(self):
user = await User.current() or {}
self.greeting = f"Welcome, {user.get('name') or user.get('sub')}"
User.current() is event-only; an on_load handler is an event and resolves
the authenticated user from the guard. See reading the current
user.
@rxe.pageWith the plugin active, rxe.App() defaults every page to login-required,
including pages added via app.add_page(...) or a plain @rx.page. Opt out with
auth=False:
app.add_page(index, route="/", auth=False)
Because plain @rx.page takes no auth argument, use
@rxe.page(auth=False) to opt a decorated page out.
A page cannot take a callable auth= directly. If AuthPlugin(auth=...) is
callable, untagged pages (@rxe.page() / @rx.page / app.add_page) run it on
load. An authenticated visitor who fails the check is redirected to the
forbidden_endpoint (/forbidden by default). An explicit
@rxe.page(auth=True) only requires login and does not run the callable
default.
Protect an event handler with @rxe.event. Here auth accepts a bool or a
callable check.
def event(
fn=None,
*,
auth: bool | EventAuthCheck = ...,
**event_kwargs,
)
Works bare (@rxe.event) or called (@rxe.event(auth=...)), and can wrap a raw
function or an already-converted EventHandler. Extra **event_kwargs are
forwarded to rx.event: background, stop_propagation, prevent_default,
throttle, debounce, and temporal.
class DemoState(rx.State):
@rxe.event # default auth=True: anonymous callers are redirected to /login
async def protected_action(self):
user = await User.current() or {}
return rx.toast(f"Hello {user.get('name') or user.get('sub')}!")
@rxe.event(auth=False)
def public_action(self):
"""Anyone may call this."""
...
A failed authorization check on an event handler shows the
"Action not allowed" toast (see
authentication vs authorization).
Base (state) fields are protected by default. A plain rx.field(...), or a bare
annotation, on one of your state classes is protected and dropped from the state
delta until the user is resolved. Use rxe.field to opt a field out or attach a
check.
def field(
default=...,
*,
auth: bool | FieldVarAuthCheck = ...,
default_factory=None,
is_var=True,
)
class DemoState(rx.State):
# Protected by default; dropped from the delta until login.
notes: rx.Field[str] = rx.field("These notes are only sent once you log in.")
# Public; always sent to the client.
loading: rx.Field[bool] = rxe.field(False, auth=False)
A withheld field is replaced in the delta with its declared default (the value baked into the frontend bundle) until the user is resolved.
Computed vars are protected by default and withheld until login. Wrap them with
@rxe.var.
def var(
fget=None,
*,
auth: bool | FieldVarAuthCheck = ...,
**var_kwargs,
)
Usable bare (@rxe.var) or called (@rxe.var(auth=..., initial_value=...)).
Extra **var_kwargs are forwarded verbatim to rx.var: initial_value,
cache, deps, auto_deps, interval, and backend.
# Always pair a protected var with `initial_value`
A protected var is withheld until the user is resolved. Set `initial_value=` to a placeholder baked into the frontend bundle and shown until the real value is delivered after login.
class DemoState(rx.State):
@rxe.var(initial_value="🔒 log in to reveal this")
def protected_tip(self) -> str:
"""Protected by default; the placeholder shows until a user is resolved."""
return "✅ Delivered only to logged-in users."
@rxe.var(auth=False)
def public_label(self) -> str:
"""Opened up to anonymous visitors."""
return "Anyone can read this."
For finer-grained control than "any authenticated user," attach a callable
auth= check to the specific events, vars, or fields that need it. A check is a
function that receives a single context object and returns a bool (or an
awaitable of one):
def check(ctx) -> bool: ... # sync
async def check(ctx) -> bool: ... # async
A check runs only after authentication succeeds. Anonymous callers are redirected to login before the check runs, and a resolved user is always present inside the check.
Each surface passes a different context, all carrying the current user as
ctx.auth_user_state (an AuthUserState). Import them from
reflex_enterprise.auth:
| Context | Surface | Extra attributes |
|---|---|---|
EventAuthContext | event handler | event_handler (the gated handler), payload (the event payload dict) |
VarAuthContext | field / computed var | field_or_var (the Var, or None) |
PageAuthContext | page (callable global default only) | None |
Read the user's claims from ctx.auth_user_state.userinfo, a plain dict:
from reflex_enterprise.auth import EventAuthContext, VarAuthContext
def is_admin(ctx: EventAuthContext) -> bool:
"""Event check: allow only members of the ``admins`` group."""
return "admins" in (ctx.auth_user_state.userinfo.get("groups") or [])
def is_staff(ctx: VarAuthContext) -> bool:
"""Field/var check: allow only staff."""
return "staff" in (ctx.auth_user_state.userinfo.get("groups") or [])
class DemoState(rx.State):
@rxe.event(auth=is_admin) # authorization failure -> "Action not allowed" toast
def admin_action(self):
return rx.toast.success("Admin action executed.")
# Withheld unless the staff check passes; the placeholder shows otherwise.
audit_log: rx.Field[list[str]] = rxe.field([], auth=is_staff)
@rxe.var(auth=is_staff, initial_value="🔒 staff only")
def staff_view(self) -> str:
return "✅ staff-only computed var."
Annotate ctx with the AuthContext union to make one function usable on any
surface; annotate it with a single context type to restrict it (and get exact
autocomplete). isinstance(ctx, EventAuthContext) narrows the union inside the
body:
from reflex_enterprise.auth import AuthContext, EventAuthContext
def is_admin(ctx: AuthContext) -> bool:
"""Usable as an event, field, var, or global-default check."""
return "admins" in (ctx.auth_user_state.userinfo.get("groups") or [])
# Cached claims can lag the IdP by up to 30 minutes
`ctx.auth_user_state.userinfo` is served from a server-side cache refreshed at most every 30 minutes. The access token is re-read at most about every 60 seconds. Checks that depend on `groups` or roles may see IdP-side changes, such as group revocation, up to 30 minutes late within one token's life. The cache is invalidated whenever the access token changes, so short-lived-token setups using a refresh token (`extra_scopes=["offline_access"]`) pick up changes sooner. If revocation must take effect immediately, check your database or authorization service in an async check instead of relying on cached claims.
Most checks can be sync. Read OIDC claims from
ctx.auth_user_state.userinfo:
import reflex as rx
import reflex_enterprise as rxe
from reflex_enterprise.auth import AuthContext
def is_org_admin(ctx: AuthContext) -> bool:
return "admins" in (ctx.auth_user_state.userinfo.get("groups") or [])
class DemoState(rx.State):
@rxe.event(auth=is_org_admin)
async def privileged_action(self):
return rx.toast.success("Done.")
@rxe.var(auth=is_org_admin, initial_value="🔒 admins only")
def admin_view(self) -> str:
return "✅ admin-only value."
Request extra_scopes=["groups"] during OAuth login when checks depend on the
groups claim.
A check may also be async. The framework awaits it at the right point: the per-event gate for handlers, or delta resolution for fields and vars. Use an async check when it calls async APIs, for example:
await ctx.auth_user_state.get_state(...)
when the policy input is stored in state.# One function, one auth value
The same function cannot back two surfaces with different `auth` values, such as one var with `auth=True` and another with `auth=False` sharing a single getter. That raises `ValueError`. Reusing a function with the same auth value is supported; otherwise define a separate function per surface.
The two failure modes are deliberately different. Applied per surface against the resolved user:
| Situation | Outcome |
|---|---|
auth=False | Allow. |
| Not logged in (no user resolved) | Authentication failure. Handled before any check runs. |
Logged in and auth=True | Allow. |
| Logged in and the check returns truthy | Allow. |
| Logged in and the check returns falsey or raises | Authorization failure. Never a login redirect. |
What each failure does depends on the surface:
| Surface | Authentication failure (anonymous) | Authorization failure (check said no) |
|---|---|---|
| Event handler | block + redirect to /login | block + "Action not allowed" toast |
| Page | redirect to /login (with redirect_to) | redirect to /forbidden |
| Field / computed var | withheld (placeholder / default shown) | withheld (placeholder / default shown) |
Two properties follow from the ordering: a check never runs for an anonymous caller, and a check that raises fails closed. Exceptions are treated as deny results.
Protected base fields are dropped from the state delta, and protected computed vars are withheld, for any caller who isn't authorized to see them. A sync check is evaluated inline as the delta is built; an async check is deferred and awaited during delta resolution.
The hydrate event runs before the auth cookies are known. Even for a
logged-in user, protected values are withheld at first because the user has not
been resolved yet. Once an event resolves an authenticated user (for example,
the page guard on a protected page), the protected names are re-delivered in
that event's delta, filtered against the resolved user.
Set initial_value on protected computed vars. The placeholder is baked into
the frontend bundle and shown until the real value arrives after login.
On logout, each non-exempt state's protected surface is reset. This prevents one user's session data from leaking to the next user on the same client token:
Public (auth=False) fields and vars are preserved across logout. They are
not part of the authenticated session.
The plugin installs middleware for the configured logout_endpoint. Cross-site
GET navigations to /logout are blocked when the browser sends
Sec-Fetch-Site: cross-site; those requests are redirected to the frontend root.
Same-origin logout requests continue normally. No configuration is required.
# Only covers the backend-served frontend
This guard applies only when the backend serves the compiled frontend, which is the default fullstack deployment. In a split frontend/backend deployment, the logout page is served elsewhere and is **not** protected. Add a cross-site guard there.
Some state classes are never protected and never gated:
reflex or reflex_enterprise.OIDCAuthState subclass, including user-defined auth providers.These exemptions let provider states read auth cookies before login and let the page guard resolve the current user without being gated.
Import the User facade to read the current user from either the frontend or the
backend:
from reflex_enterprise.auth import User
User is an alias of reflex_enterprise.auth.AuthUserState and may be used
interchangeably.
Frontend Vars: embed these class-level descriptors directly in components.
They bind to AuthUserState, populated after login by the provider that
authenticated the user. Each is typed str and is empty ("") until login:
| Attribute | Value |
|---|---|
User.name | The user's name claim. |
User.email | The user's email claim. |
User.sub | The user's subject identifier. |
User.picture | The user's picture URL. |
rx.avatar(src=User.picture, fallback="U", size="5")
rx.heading(User.name, size="6")
rx.text(User.email, color_scheme="gray")
rx.cond(User.sub != "", rx.text("Signed in"), rx.link("Log in", href="/login"))
The common OIDC claims name, email, sub, and picture are projected as
frontend Vars. The full userinfo dict is server-only and never serialized. To
render any other claim (groups, roles, a custom field) in a component, opt it
onto the frontend by declaring a computed var on an AuthUserState substate:
import reflex as rx
from reflex_enterprise.auth import AuthUserState
class UserExtras(AuthUserState):
@rx.var
def groups(self) -> list[str]:
return self.userinfo.get("groups") or []
# in a component:
rx.cond(UserExtras.groups.contains("admins"), rx.badge("Admin"), rx.fragment())
For backend checks, read the same claims via await User.current() or
ctx.auth_user_state.userinfo.get(...). See
the claims a provider
returns.
Backend: use these inside an event handler. current() and
current_provider() are async:
| Call | Returns |
|---|---|
await User.current() | The current user's OIDCUserInfo claims dict for this event, or None when anonymous. |
await User.current_provider() | The provider class that actually authenticated this event's user, or None. Correct in multi-provider setups. |
User.logout | Event handler that signs the current user out. Bind it (on_click=User.logout) or return it from a handler. Redirects to the home page (/) when anonymous. |
Inside an authorization check, ctx.auth_user_state.provider returns the same
provider class.
OIDCUserInfo is a plain dict at runtime. Read claims with .get(...):
class DemoState(rx.State):
@rxe.event # default auth=True
async def whoami(self):
user = await User.current() or {}
return rx.toast(f"You are {user.get('name') or user.get('sub')}.")