For AI agents: the complete documentation index is at llms.txt. Markdown versions are available by appending .md or sending Accept: text/markdown.
Reflex Logo
Docs Logo
Enterprise

/

Auth

/

Example App

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:

You wantWriteKind
Any logged-in user may use itnothing — the secure defaultauthentication
Anyone may use it, logged in or notauth=Falseopt-out
Only some logged-in users may use itauth=my_checkauthorization

What we will build

SurfaceRoute / nameauth=Who reaches it
Landing page/Falseeveryone
Dashboard page/notesdefault (True)logged-in users
add_note, join_team_board eventsdefault (True)logged-in users
clear_all_notes eventis_adminthe admins group
dismiss_promo eventFalseeveryone
show_promo fieldFalseeveryone
notes field, note_count vardefault (True)logged-in users

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

Expand

Four things to notice:

  • The notes are genuinely shared. NotesState is 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 protected join_team_board event does the linking, and the page guard guarantees a resolved user before it runs.
  • UserExtras exists for the UI, not for security. The common claims (name, email, sub, picture) are already frontend Vars on User; any other claim you want to render — here, whether to show the admin button — needs a computed var on an AuthUserState substate. The enforcement is the auth=is_admin on the handler, never the rendering.
  • await User.current() returns the claims dict inside any event handler, or None when anonymous. Because add_note requires 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 sees 0 instead 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.

Expand

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, not email. sub is the stable identifier for an identity; email is optional in OIDC and can change. In a multi-provider app key on (provider_name, sub), since sub is 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

  1. 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 /login and /logout.
  2. Express authorization with auth=, in one place. Checks live in authz.py, named for the question they answer, and attach at the surface they protect. Handlers contain business logic, not policy.
  3. Opt public surfaces out deliberately. Every auth=False in 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.
  4. Give protected values presentable placeholders. Declared defaults and initial_value are what logged-out visitors see. Make them look intentional (0, an empty list, a "log in to see this" string), not broken.
  5. 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 of rxe.App(). With the plugin registered, rx.App() raises a ConfigError at 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 in rxconfig.py, and rxe.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_value until login. Mark truly public surfaces auth=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_mismatch at 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.
  • 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.
Built with Reflex