SfSkills Domains Docs GitHub

Home / security / visualforce-security-and-modernization

visualforce-security-and-modernization

Use when hardening or modernizing legacy Visualforce pages — covers the platform CSRF token model and when disabling it is a security regression, view state encryption guarantees and the 170 KB ceiling, FLS/CRUD enforcement gaps on `<apex:outputField>` and on getters that return sObjects, `<apex:includeScript>` interaction with the org Content Security Policy, hosting LWC inside a VF page via `lightning:container` / `lightning-out`, and the retire-vs-harden-vs-leave-alone decision for an inventory of legacy pages.

Open the skill folder on GitHub


Visualforce Security And Modernization

Activate this skill when an org has a population of Visualforce pages — usually a mix of legacy admin tools, customer-facing pages on Experience Cloud, and PDF generators — and a security review or LWC migration program is forcing decisions on each one. The skill is about the retire-vs-harden-vs-leave-alone choice plus the security baseline for whatever stays on Visualforce. Greenfield Visualforce architecture (controller patterns, PDF, view state strategy) belongs in apex/visualforce-fundamentals.


Before Starting

Gather this context before proposing changes to any Visualforce page:


Core Concepts

Concept 1 — The CSRF protection model and when disabling it is a regression

Visualforce ships with anti-CSRF token protection enabled at the platform level for <apex:form> POST submissions. The token is automatically generated per session and verified on submit; a third-party site cannot forge a POST that performs DML on behalf of a logged-in user without the token.

What the protection does NOT cover:

The csrfProtection page attribute on <apex:page> can disable the token. The defensible reasons to do so are extremely rare (e.g., a legacy page that must accept third-party POST submissions and uses an alternative authentication model). The non-defensible reasons that show up in real codebases include "we couldn't get an external system to send the token" — fix the integration to send the token (or move it off Visualforce) instead.

Concept 2 — FLS enforcement on <apex:outputField>, <apex:inputField>, and getter-returned sObjects

Salesforce automatically enforces FLS on certain Visualforce bindings, but the rules are not symmetric and the gaps are where bugs live.

Binding patternFLS enforced?Notes
<apex:inputField value="{!account.Name}"/> bound to a standard controller's recordYesHidden if no read access; read-only if no edit access.
<apex:outputField value="{!account.Name}"/> bound to a standard controller's recordYesField is hidden if user has no read access. Empty string is rendered.
<apex:outputText value="{!account.Name}"/>NooutputText does not check FLS. The field value is rendered regardless.
<apex:outputField value="{!myCustomGetter}"/> where the getter returns an sObject from a custom queryNoIf the controller queries the record without WITH USER_MODE or explicit isAccessible() checks, the field is rendered regardless of FLS.
<apex:repeat var="r" value="{!myList}"> then <apex:outputField value="{!r.SensitiveField__c}"/>PartialFLS is enforced on the outputField binding only if the underlying SOQL was executed with FLS enforcement. If the controller used a permissive query, the field renders even though outputField is the binding.
Formula field referenced via <apex:outputField>Yes for the formula's read access, but not for the underlying fieldsIf the formula references Salary__c and the user has no FLS access to Salary__c but does have access to the formula, the formula's computed value is shown. This is documented platform behavior and a frequent surprise.

The reliable pattern: query with WITH USER_MODE, bind to <apex:inputField> / <apex:outputField>, and treat <apex:outputText> as user-supplied display text — never use it for sObject-field output.

The No and Partial rows above describe a controller whose class is pinned below apiVersion 67.0, where an unqualified query runs in system mode. At 67.0+ that same query defaults to user mode and is already FLS-enforced, and WITH SECURITY_ENFORCED no longer compiles — so an unannotated query is a finding only after you have checked the version in the .cls-meta.xml. Write WITH USER_MODE either way: it is the read idiom at every version from 57.0 up, and at 67.0+ it states the intent explicitly instead of leaving the reader to infer it.

Concept 3 — Modernization decision: retire vs harden vs leave alone

The default for a working page is leave alone. Modernization work is justified by one of these signals:

SignalDispositionRationale
Page is broken in Lightning Experience (cross-frame JS, classic-only CSS) and used by internal users dailyRewrite to LWCLEX iframe issues compound over time; a working LWC is a one-time cost.
Page is exposed to external users (guest, customer community) AND has FLS gaps OR uses <apex:dynamicComponent> for permission-driven UIRewrite to LWCLWC has a stronger security default (Lightning Locker, manifest-declared permissions). Hardening VF for external surfaces is high-effort and easy to get wrong.
Page is admin-only, internal-network, low-traffic, controller is sharing-aware, no security findingsLeave aloneThe migration ROI is negative.
Page is a PDF generator using renderAs="pdf"Leave aloneLWC has no PDF rendering equivalent — a rewrite either keeps the VF page or introduces an external PDF service.
Page uses <apex:actionFunction> to call a controller method from JS, doing DMLHarden in placeThe pattern is dated but the security model is sound if FLS and CSRF are correct. Migrating the pattern alone is not worth a rewrite.
Page uses <apex:dynamicComponent> for permission-driven branchingRewrite to LWC<apex:dynamicComponent> is hard to reason about for security review and has been overtaken by simple conditional rendering in LWC.

The output of this decision pass is a per-page label — rewrite-lwc, harden-in-place, or leave-alone — plus a sunset date for the rewrite candidates. Implement in batches; do not attempt a full-org migration in one quarter.

Concept 4 — Visualforce + LWC interop

Two interop directions matter, and they have different mechanics:

The interop pattern that does NOT work cleanly: passing complex objects between the host VF controller and an embedded LWC. The bridge is JS-side; serialize at the boundary, do not assume Apex-typed objects survive.


Common Patterns

Pattern 1 — Hardening a CSRF-disabled page back to default

When to use: Audit finds csrfProtection="false" on a page and the original justification is "needed for the integration." 95% of the time the integration can be fixed.

How it works:

  1. Identify what the page is doing on POST. If it is taking submissions from a third-party system, capture the integration spec.
  2. Re-enable CSRF protection (remove csrfProtection="false" or set it to "true"). Verify the third-party submission now fails.
  3. Make the third-party system either (a) hit a Salesforce REST endpoint with proper OAuth instead of the VF page, or (b) load the VF page first via GET to obtain the form token, then submit. Option (a) is almost always the right answer for any submission newer than 2018.
  4. If neither is feasible, the next-best option is moving the endpoint to an Apex REST class (@RestResource) with explicit authentication — VF pages are not a good integration surface in 2026.

Why not the alternative: Leaving CSRF disabled means an attacker who can convince a logged-in user to load a malicious page can submit arbitrary DML on their behalf. The risk is real and the remediation is documentation-bounded.

Pattern 2 — Adding FLS guards to a custom-controller getter

When to use: Audit finds a VF page where {!myCustomMethod} returns an sObject and the underlying controller does [SELECT Salary__c FROM Employee WHERE Id = :id] without WITH USER_MODE, and the controller's .cls-meta.xml pins apiVersion at 66.0 or below. External users see the field. (At 67.0+ that query already runs in user mode; adding the clause there is a legibility fix, not a leak fix.)

How it works: Switch the SOQL to WITH USER_MODE:

public Employee__c getEmployee() {
    if (this.cachedEmployee != null) return this.cachedEmployee;
    this.cachedEmployee = [
        SELECT Id, Name, Salary__c
        FROM Employee__c
        WHERE Id = :this.empId
        WITH USER_MODE
        LIMIT 1
    ];
    return this.cachedEmployee;
}

WITH USER_MODE enforces both CRUD and FLS for the running user, and it fails closed rather than filtering: one inaccessible field in the SELECT list throws System.QueryException and the getter returns no record at all. Budget for that — a user who could previously load the page will now see an error, so either narrow the SELECT to the fields that audience can read, or use Security.stripInaccessible(AccessType.READABLE, records).getRecords() when partial rendering is the better outcome.

Why not the alternative: A with sharing class still returns FLS-restricted fields; the keyword only governs records. Schema.sObjectType.X.fields.Y.isAccessible() works but is verbose and easy to miss for the 17th field.

Pattern 3 — Hosting an LWC inside an existing Visualforce page (incremental modernization)

When to use: A 2,000-line VF page does one thing well that the team needs to modernize incrementally — e.g., the data table at the top — without rewriting the whole page.

How it works:

<apex:page sidebar="false">
    <apex:includeLightning/>
    <div id="lwc-container"></div>
    <script>
        $Lightning.use("c:MyEmbedApp", function() {
            $Lightning.createComponent(
                "c:myDataTable",
                { recordIds: "{!recordIdsJson}" },
                "lwc-container",
                function(cmp) { /* component ready */ }
            );
        });
    </script>
</apex:page>

Where c:MyEmbedApp is a Lightning Out app (.app file with <aura:application access="GLOBAL" extends="ltng:outApp">), and c:myDataTable is the LWC. The LWC runs under Lightning Web Security; Apex calls from inside it use @AuraEnabled methods, not the host VF controller.

Why not the alternative: Rewriting a 2,000-line VF page in one shot is high-risk and high-cost. Lightning Out lets you replace one component at a time. Eventually the VF shell is empty and can be retired.


  1. Inventory. Enumerate every Visualforce page in the org with: usage count (last 90 days), last-modified date, controller name, audience (internal / partner / community / guest), and whether it is renderAs="pdf". Run the bundled scripts/check_visualforce_security_and_modernization.py against your retrieved metadata to flag the structural risks (CSRF disabled, controller without with sharing, getter returning sObject in a non-USER_MODE class).
  2. Triage. Apply the decision table in Core Concept 3. Tag every page rewrite-lwc, harden-in-place, or leave-alone. Most should be leave-alone — modernization work is justified per page, not orgwide.
  3. Harden the harden-in-place set. For each: re-enable CSRF if disabled, verify FLS enforcement on every getter that returns an sObject, confirm controller is with sharing, audit <apex:page action> for DML, replace <apex:outputText> with <apex:outputField> for sObject fields. Run the checker after each round.
  4. Plan the rewrite-lwc set. Map LWC equivalents for each VF feature (component decomposition). For pages doing PDF, decide whether the rewrite keeps a slim VF wrapper just for renderAs="pdf". Set sunset dates 3–6 months out and add a deprecation banner to the existing page (<apex:outputPanel> with a notice).
  5. Implement Lightning Out interop where helpful. If a page is too big to rewrite at once, embed LWCs incrementally using the pattern in Common Pattern 3. Track the proportion of the page that is now LWC vs legacy VF; the page is ready to retire when the VF shell is empty.
  6. Retire and remove. Once the LWC equivalent is live and adopted (verify via login/usage metrics), set the original VF page to availableInTouch="false" and remove from any quick action / button references. Wait one release, then delete.
  7. Re-measure. Re-run the inventory and checker quarterly. The set of leave-alone pages will shift over time as audiences change (e.g., a page suddenly exposed to a new community license type joins the harden-or-rewrite list).

Package files