Custom authentication views sit close to the trust boundary. They render login forms, MFA prompts, error messages, identity-provider choices, consent pages, and the small pieces of state that keep an authentication flow moving.
That is why the ProAuth v3 view migration is more than a syntax change. Razor .cshtml custom views allowed deep customization, but they also allowed arbitrary server-side C# execution inside customer-maintained templates. ProAuth v3 moves custom authentication views to Fluid/Liquid .liquid templates: still flexible enough for tenant, client-application, and subscription branding, but rendered inside a constrained template model with ProAuth building-block tags for the security-sensitive pieces.
The result is a cleaner split of responsibility. Product code owns anti-forgery handling, flow state, validation rendering, localized labels, external identity-provider buttons, captcha integration, and other authentication mechanics. Custom templates own presentation, layout, copy structure, and approved conditional rendering.
flowchart LR
subgraph V2["ProAuth 2.x custom view"]
Razor["Razor .cshtml"]
CSharp["Inline C# logic<br/>Injected services<br/>Hidden flow fields"]
Bootstrap["Bootstrap and custom CSS"]
end
subgraph Upgrade["ProAuth 3 upgrade"]
Archive["Archive .cshtml under _legacy/"]
Rewrite["Rewrite required overrides as .liquid"]
Validate["Validate Fluid syntax on API save"]
end
subgraph V3["ProAuth 3.x custom view"]
Fluid["Fluid .liquid"]
Tags["ProAuth building-block tags<br/>auth_form, auth_flow_id, inputs, validation"]
Theme["CSS variables and custom assets"]
Cache["PubSub cache invalidation<br/>after API changes"]
end
Razor --> Archive --> Rewrite --> Fluid
CSharp --> Validate --> Tags
Bootstrap --> Theme
Fluid --> Cache
classDef old fill:#fff7ed,stroke:#f97316,color:#431407
classDef step fill:#eef6ff,stroke:#2563eb,color:#10233f
classDef new fill:#ecfdf5,stroke:#16a34a,color:#052e16
class Razor,CSharp,Bootstrap old
class Archive,Rewrite,Validate step
class Fluid,Tags,Theme,Cache new
What happens to existing Razor views
During the ProAuth v3 database deployment, existing .cshtml custom views are not deleted. They are moved under a _legacy/ path prefix so teams can still inspect them through the Management API or Admin UI while rebuilding the active customizations.
For example:
| ProAuth 2.x custom view | Archived ProAuth 3.x location |
|---|---|
/Views/tenant/{id}/Authentication/Login.cshtml | /Views/_legacy/tenant/{id}/Authentication/Login.cshtml |
/Views/app/{id}/Shared/_Layout.cshtml | /Views/_legacy/app/{id}/Shared/_Layout.cshtml |
Those archived Razor views are reference material only. They are not rendered by the ProAuth v3 Fluid view engine. Once the Fluid replacements have been created, tested, and deployed, remove the archived _legacy/ entries through the Management API or Admin UI.
Custom assets are different. CSS files, images, fonts, and other entries in CustomAsset are not archived as part of the Razor view migration. They continue to be served from /assets/{location}, though CSS that depended on Bootstrap class names usually needs review because the v3 authentication views use the new ProAuth view system and CSS custom properties.
Think in building blocks, not copied server logic
The migration should not copy server-side C# logic from Razor into Fluid. That is the wrong mental model and, in many cases, it is not possible by design.
Fluid templates can use normal Liquid syntax for presentation logic: if, for, output expressions, filters, includes, layouts, and sections. They run inside the Fluid sandbox and cannot execute arbitrary .NET code. ProAuth registers a fixed set of building-block tags for authentication-specific behavior, and those tags are the migration target for the parts of old Razor views that touched forms, labels, validation, flow state, captcha, and identity-provider rendering.
The most common migration mappings are:
| Razor pattern | Fluid replacement |
|---|---|
@model LoginViewModel | No directive; use the automatic model object |
@{ Layout = "_Layout"; } | {% layout '_Layout' %} |
@RenderBody() | {% renderbody %} |
@section scripts { ... } | {% section 'scripts' %}...{% endsection %} |
@RenderSection("scripts") | {% rendersection 'scripts' %} |
@Html.AntiForgeryToken() | Built into {% auth_form controller: '...', action: '...' %} |
<form asp-controller="UserStore" asp-action="Login"> | {% auth_form controller: 'userstore', action: 'login', method: 'post' %}...{% endauth_form %} |
| Hidden fields for flow state | {% auth_flow_id %} |
<input asp-for="LoginName" /> | {% text_input for: 'LoginName', label_key: '...' %} |
| Password input markup | {% password_input for: 'Password', label_key: '...' %} |
| Checkbox markup | {% checkbox for: 'Field', label_key: '...' %} |
| Validation summary markup | {% validation_summary %} |
| Error or info message markup | {% error_info_block %} |
| Label-service calls for headings | {% auth_title key: '...', tag: 'h1' %} |
| Localized links | {% link key: '...', href: model.SomeUrl %} |
| External IdP button loops | {% idp_button_list %} |
| Language selector markup | {% language_selector %} |
| Captcha widget and scripts | {% captcha_widget %} and {% captcha_scripts %} |
| QR code image rendering | {% qr_code 'PropertyName' %} |
| Alert markup | {% alert type: 'warning', key: '...' %} |
| Inline icon markup | {% icon name: 'lock' %} |
| Password strength UI | {% password_strength for: 'Password' %} |
| Shared partials | {% include '_PartialName' %} |
This mapping is intentionally conservative. It uses tags that are documented in the ProAuth v3 authentication view customization guide and verified in the v3 Fluid tag registry. It also keeps authentication behavior inside ProAuth-owned tags rather than spreading it across customer templates.
Replace hidden-field flow state
One migration detail deserves special attention: form state.
In ProAuth 2.x Razor views, custom templates often rendered loops over hidden model properties. That pattern exposed flow values such as return URLs, tenant IDs, or authentication scheme details in the HTML source. ProAuth v3 moves authentication flow state server-side. A Fluid form should include one opaque flow identifier:
{% auth_form controller: 'userstore', action: 'login', method: 'post' %}
{% auth_flow_id %}
{% text_input for: 'LoginName',
label_key: 'userlogin-username-input-placeholder',
autocomplete: 'username',
required: true %}
{% password_input for: 'Password',
label_key: 'userlogin-password-input-placeholder',
autocomplete: 'current-password',
required: true %}
{% validation_summary %}
{% error_info_block %}
{% submit_button key: 'userlogin-sign-in' %}
{% endauth_form %}
{% auth_flow_id %} renders a single hidden __flowId field. The actual flow state remains on the server. That is one of the important security improvements in the v3 view model, and it is also a good reason to avoid hand-rebuilding form mechanics in raw HTML.
Use CSS variables before rewriting layouts
Not every customization needs a full template rewrite.
For simple branding changes, ProAuth v3 is designed around CSS custom properties and custom assets. Upload a tenant, subscription, or client-application stylesheet through Custom Assets, then reference it from a custom Shared/_Head.liquid partial. The default layout includes _Head, and the Fluid include hierarchy lets a scoped custom _Head.liquid override the default partial.
Typical branding variables include:
:root {
--proauth-primary: #0041c8;
--proauth-primary-hover: #0036a8;
--proauth-primary-light: #eef3ff;
--proauth-bg: #f8f9fc;
--proauth-card-bg: #ffffff;
--proauth-text: #0f172a;
--proauth-text-muted: #64748b;
--proauth-input-border: #d1d5db;
--proauth-input-focus: #0041c8;
--proauth-error: #dc2626;
--proauth-success: #16a34a;
--proauth-warning: #d97706;
--proauth-info: #2563eb;
--proauth-font-family:
"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
--proauth-input-radius: 0.5rem;
--proauth-button-radius: 0.5rem;
--proauth-logo-url: url("/assets/tenant/{tenantId}/logo.svg");
--proauth-logo-sm-url: url("/assets/tenant/{tenantId}/logo-horizontal.svg");
}
For dynamic asset paths in Fluid templates, use the built-in context variables: {{ tenant_id }}, {{ subscription_id }}, and {{ client_app_id }}. The Custom Assets documentation shows the same pattern for CSS, images, JavaScript, and fonts served through /assets/.
This approach keeps branding changes small. Teams can often preserve the default authentication flow and only override _Head.liquid plus a stylesheet, instead of taking ownership of the entire layout.
Validate before rollout
The Management API validates Fluid template syntax when a custom view is created or updated. Invalid Fluid content is rejected with 400 Bad Request and parse error details. This catches broken syntax before a malformed template becomes an active login page.
Validation is not the same as end-to-end login testing. A template can parse correctly and still be missing a required form element, an expected script section, or a route-specific model condition. Test each overridden path that matters to your deployment: login, IdP selection, MFA verification and setup, password reset, consent, logout, and error handling.
Cache behavior also changes the rollout shape. Fluid templates are compiled and cached in memory. When a custom view is created, updated, or deleted through the Management API, ProAuth publishes an invalidation event so instances refresh the affected view. In multi-instance deployments, the docs describe this as PubSub-based invalidation that typically takes effect within seconds. Plan smoke tests with that propagation window in mind rather than assuming every pod will render the new view in the same instant.
A practical migration order
Start with inventory. Export the current .cshtml custom views and list the active paths by scope: tenant, client app, subscription, and default. Pay special attention to MFA custom views because v3 also standardizes MFA naming and uses the v3 view paths documented in the migration guide.
Then decide how much customization is still needed. Some old Razor views existed only to adjust colors, logos, fonts, or simple layout details. Those should move to CSS variables and Custom Assets where possible. Views that really change page structure should become .liquid templates using ProAuth building-block tags.
During the upgrade, remember the boundary: old .cshtml files are archived under _legacy/ and are not rendered. Active v3 custom views must be recreated as .liquid entries at the correct /Views/... locations. After creating or updating views through the API, allow cache invalidation to propagate, then verify the affected authentication routes in the browser.
Finally, clean up deliberately. Once the Fluid templates are deployed, tested, and owned by the team, delete the archived _legacy/ Razor views. Leaving them around is useful during migration; keeping them indefinitely makes later audits harder.
The detailed source of truth is the ProAuth migration guide, the Auth View Customization documentation, the Custom Assets documentation, and the ProAuth 3.0.0 release notes.