New in reflex-enterprise v0.9.1.
Example: A Complete App
This page builds one small, complete app with rxe.AuthPlugin: Team Notes,
a shared team notepad with a public landing page, a login-protected dashboard,
and one admin-only action. It shows the full pattern — configuration, checks,
state, pages — in about 100 lines of code, applying the practices from
secure by default.
If you have not set up the plugin before, read the overview first.
Authentication and authorization
The plugin separates two questions, and the design of your app follows from keeping them apart:
- Authentication answers "who is this user?" The plugin owns it
completely: it runs the OIDC flow against your identity provider and
registers
/login,/logout,/callback, and/forbidden. You never write a login form, an OAuth redirect, or a token exchange. - Authorization answers "what may this user do?" You own it, and you
express it with the
auth=argument on events, fields, computed vars, and the plugin itself. An authorization check runs only after authentication succeeds, so a check always has a resolved user to reason about.
Pick the auth= value from the question you are answering:
What we will build
The project has three files:
The notes live in an rx.SharedState linked to one team token, so every
signed-in user reads and writes the same board and edits propagate live. A real app would also persist the notes to a
database — shared state is memory-resident — but nothing about the auth
pattern changes.
Step 1: Configure the plugin
Add rxe.AuthPlugin() to rxconfig.py. The provider is configured entirely
through the OIDC_* environment variables — set them in your deployment
environment, or default them here for local work:
Placeholder values are enough to import, compile, and preview the app. OIDC discovery runs only when a user first logs in, so the app builds before any real identity provider exists. See providers for named and multi-provider setups.
Step 2: Write the authorization checks
Keep every check in one module, and name each one for the question it answers.
A check receives a context object and returns a bool; read the user's claims
from ctx.auth_user_state.userinfo:
The isinstance guard is deliberate: some providers serialize groups as a
single string, and Python's in on a string is a substring test —
"admins" in "not-admins" is true. Checking the type first fails closed on
any unexpected claim shape.
Annotating ctx with the AuthContext union makes is_admin usable on any
surface — an event, a field, a var, or the plugin's global default. A check
never runs for an anonymous caller, and a check that raises fails closed. See
authorization checks
for async checks and the per-surface context types, and
testing for how to unit-test them.
Step 3: The state
The plugin protects every field, computed var, and event handler by default.
That default does most of the work: you only annotate the exceptions —
the public surfaces (auth=False) and the admin-only action
(auth=is_admin).
ExpandCollapse
Four things to notice:
- The notes are genuinely shared.
NotesStateis an : every signed-in user links to the same"team-notes"token, so an added or cleared note propagates to everyone on the dashboard. The protectedjoin_team_boardevent does the linking, and the page guard guarantees a resolved user before it runs. UserExtrasexists for the UI, not for security. The common claims (name,email,sub,picture) are already frontend Vars onUser; any other claim you want to render — here, whether to show the admin button — needs a computed var on anAuthUserStatesubstate. The enforcement is theauth=is_adminon the handler, never the rendering.await User.current()returns the claims dict inside any event handler, orNonewhen anonymous. Becauseadd_noterequires login, the user is always resolved there.- Protected computed vars get an
initial_value. The placeholder is baked into the frontend bundle and shown until the real value arrives after login, so a logged-out visitor sees0instead of a broken widget.
Step 4: The pages
Mark the landing page public with auth=False. Leave the dashboard on the
default, which requires login: anonymous visitors are redirected to /login
and returned to /notes afterward.
ExpandCollapse
The module ends with rxe.App(), not rx.App(). The enterprise app class is
what enforces the plugin's protection; with the plugin registered, a plain
rx.App() raises a ConfigError at startup.
Pages accept only a bool for auth=. To put a role check in front of whole
pages, make the check the app-wide baseline with AuthPlugin(auth=is_admin) —
authenticated visitors who fail it are redirected to /forbidden. See
the global default.
Step 5: Run it
Open the app: the landing page renders for everyone, and anyone — signed in
or not — can dismiss the promo banner. Click Open the dashboard and you
are redirected to /login; the plugin renders a button for the configured
provider. To complete a real login, register the app's
callback URL (http://localhost:3000/callback in local dev) as an allowed
redirect URI in your identity provider, and point the OIDC_* variables at
it. See deploying to production for
production callback URLs and HTTPS.
After login: the notes and the count appear, and add_note signs entries
with your name. Sign in from a second browser and the two dashboards show the
same board — a note added in one appears in the other, because both sessions
linked to the shared "team-notes" state. The clear button appears only if
your IdP puts you in the admins group — and refuses everyone else even if
they dispatch the event by hand.
When the roles are your app's, not your provider's
is_admin reads a group the identity provider already manages, which is the
cheapest check there is: ctx.auth_user_state.userinfo is a dict resolved once
at sign-in and held in memory, so it costs nothing per event.
That only works when the organization models the role in the IdP. Many apps
have roles the provider has never heard of — author, editor, reviewer —
and need an admin to promote someone from inside the app, which no OIDC flow
can do: there is no write-back to the identity provider. Those roles live in
your own table, and the check reads them through a state:
Run the loader on the pages that need it (on_load=RoleState.load_role), and
again after any write that changes a role so the cached value stays honest.
Two rules keep this both safe and fast:
- Load once, read many. A check runs on every gated event, so opening a database session inside one puts a query behind every click. Load the row into a state at sign-in and let the check read memory.
- Key the row on
sub, notemail.subis the stable identifier for an identity;emailis optional in OIDC and can change. In a multi-provider app key on(provider_name, sub), sincesubis unique only per issuer.
An async check is also where you call an external authorization service, or re-read a permission that must not be up to 30 minutes stale. See authorization checks.
The rules this app follows
- Never hand-roll authentication. No login form, no password column, no
OAuth redirect, no session cookie code. The plugin and the identity
provider own all of it; your app links to
/loginand/logout. - Express authorization with
auth=, in one place. Checks live inauthz.py, named for the question they answer, and attach at the surface they protect. Handlers contain business logic, not policy. - Opt public surfaces out deliberately. Every
auth=Falsein the app is a decision you can point at: the landing page, the promo banner's field, its dismiss event. Everything else is protected because you did nothing. - Give protected values presentable placeholders. Declared defaults and
initial_valueare what logged-out visitors see. Make them look intentional (0, an empty list, a "log in to see this" string), not broken. - Render from claims, enforce with checks. Frontend claim vars
(
User.name,UserExtras.is_admin_user) decide what to show;auth=decides what is allowed.
Common mistakes
- Using
rx.App()instead ofrxe.App(). With the plugin registered,rx.App()raises aConfigErrorat startup. Without the plugin registered, the app runs with no protection at all — nothing fails, every surface is public. Both halves are required: the plugin inrxconfig.py, andrxe.App()in the app module. - Everything looks blank or empty when logged out. That is
secure-by-default working: protected fields render their declared defaults
and protected vars their
initial_valueuntil login. Mark truly public surfacesauth=False, and give the rest placeholders that read as intentional. - Querying the database inside a check. A check runs on every gated event, so a session opened there is a query per click. Load the role into a state at sign-in and have the check read that state.
redirect_uri_mismatchat the IdP. The exact callback URL (scheme, host, and path) is not registered with the identity provider. Register the full URL for each environment. See deploying to production.- Writing the auth routes yourself. The plugin owns
/login,/logout,/callback, and/forbidden. Customize what they render via the plugin's page arguments (custom pages); never add your own routes at those paths. - Calling
register_auth_endpoints. That is the pre-plugin API. It is deprecated and will be removed in 1.0 — the plugin registers the endpoints itself. The providers page covers migrating off it.
Related
- Overview: plugin setup and the login flow, end to end.
- Secure by default: the full
enforcement model, the
auth=wrappers, and the context objects. - Providers: environment variables, scopes, and multi-provider setups.
- Testing: unit-testing checks and mock-IdP flow tests.
- Deploying to production: HTTPS, callback URLs, and troubleshooting.