Foundation: this picks up where an Experience Cloud tile launch is a session hand-off, not a login leaves off. That post explains why a Login Flow never fires for users arriving from another site. This one is what to build instead.
TL;DR
- Gate at the action, not at the login. The component that launches or navigates calls a server-side check before it does anything, and refuses if consent is outstanding.
- The dialog is presentation. The Apex check is the control. An overlay on its own only asks the question. Treating it as the answer is the common mistake.
- Use the standard Consent objects:
AuthorizationForm,AuthorizationFormText,AuthorizationFormConsent. Revisioning, effective dates and locale variants all come with them. - One configurable component covers every tier. Expose it to
lightningCommunity__Defaultwith a scope property so the same code serves platform-wide terms and per-application terms. - Most of it is config. Wording, revisions, effective dates and locales are all record data, so publishing new terms needs no deployment.
- Verify the wording on submit, not only on render, or you will record acceptance of text the user never saw.
- Match existing consent on the form, not on the wording, or adding a second locale re-prompts everyone who already accepted.
What You'll Learn
- Where the check has to run so that it cannot be skipped by the route a user takes
- Which standard objects to use, and what each one is actually for
- How to expose one component so Experience Builder can configure it per placement
- The five implementation details that decide whether this works, each of which is invisible until it bites
- Which parts an admin or legal team can change without a deployment, and the three caveats on that
- How to keep the whole thing testable, including the tests worth naming after the bug they prevent
The Problem
You need users of an Experience Cloud site to accept terms before they use it. Terms of use, a privacy notice, an attestation, a clinical or financial declaration. The requirement is always the same shape: nobody gets to the functionality until they have said yes, and you need a record of what they said yes to.
The native answer is a Login Flow, and on a single site with direct entry it works. The moment you have more than one site and a hub that sends people between them, it stops working, because arriving from another site in the same org is a session hand-off rather than a login. The Login Flow never fires for the people who came through the front door, which after launch is nearly everyone.
So you reach for a component instead. That is the right instinct, and it is where most implementations then go wrong in a quieter way: the component shows a dialog, the user accepts, a record is written, and everyone moves on. Nothing in that sequence stops a user who never saw the dialog. The overlay is a picture of a gate rather than a gate.
The version problem arrives next. Terms change. You need to know which revision each user accepted, you need to re-ask when the wording materially changes, and you need to not re-ask when it has not. Most hand-rolled implementations discover this after the first revision ships, when everyone is either re-prompted or nobody is.
Common questions this article answers:
- Where does the check have to live so a user cannot route around it?
- Should I build a custom object for acceptance records?
- How do I handle terms changing without re-prompting everyone unnecessarily?
- How do I stop the user interacting with the page behind the dialog?
Quick Answer
Put the presentation in a Lightning Web Component and the enforcement in Apex, and call the Apex from whatever component performs the action. Expose the gate to lightningCommunity__Page and lightningCommunity__Default with a scope property so one bundle serves every tier of terms you have. Store acceptance as an AuthorizationFormConsent pointing at an AuthorizationFormText, which belongs to an AuthorizationForm carrying RevisionNumber, EffectiveFromDate and EffectiveToDate, so "which wording is in force today" is a query rather than custom logic. Resolve the requirement with an Apex method that returns null when nothing is outstanding, expose a boolean isOutstanding(scopeKey) alongside it, and have the launching component await that boolean before it navigates. On submit, pass back the id of the wording that was actually rendered and reject the write if it no longer matches what is in force. Match a user's existing consent on the parent form rather than the specific wording record, insert with AccessLevel.USER_MODE and an explicit OwnerId, and short-circuit if a row already exists so two browser tabs cannot produce two records.
The data model: use what the platform already has
Resist the custom object. Salesforce ships a consent model and it is a good fit.
AuthorizationForm is the form itself, and it carries the lifecycle: Name, RevisionNumber, EffectiveFromDate, EffectiveToDate, and a DefaultAuthFormTextId. "Which version is in force right now" becomes a filter rather than something you maintain:
List<AuthorizationForm> inForce = [
SELECT Id, Name, RevisionNumber, DefaultAuthFormTextId
FROM AuthorizationForm
WHERE Name = :formName
AND EffectiveFromDate <= TODAY
AND (EffectiveToDate = NULL OR EffectiveToDate >= TODAY)
ORDER BY EffectiveFromDate DESC, RevisionNumber DESC
LIMIT 1
];
AuthorizationFormText is the wording, and there can be more than one per form. It holds SummaryAuthFormText, DetailAuthorizationFormText, FullAuthorizationFormUrl and Locale. That last field is why multiple texts per revision exist, and it is the source of a trap covered below.
AuthorizationFormConsent is the acceptance record: ConsentGiverId, AuthorizationFormTextId, ConsentCapturedDateTime, ConsentCapturedSource and a Status of Signed or Rejected. Recording a decline as a first-class status rather than an absent row matters, because "declined" and "never asked" are different facts and you will eventually need to tell them apart.
What you still supply yourself is the mapping from a scope to a form name. Custom metadata is the right home: one record for the platform-wide terms, one per application, each naming the AuthorizationForm it requires. That keeps the wording in data and the routing in configuration, so neither needs a deployment to change.
The component contract
Expose one component and let Experience Builder configure it:
<targets>
<target>lightningCommunity__Page</target>
<target>lightningCommunity__Default</target>
</targets>
<targetConfigs>
<targetConfig targets="lightningCommunity__Default">
<property name="scopeKey" type="String" label="Scope" default="PLATFORM"
description="PLATFORM for the site's own terms, or an application's
developer name for that application's terms."/>
</targetConfig>
</targetConfigs>
lightningCommunity__Default is the part that earns its place. Without it the property is not configurable in Experience Builder, and you end up with a component per scope, which is the same code three times and three places to fix a bug.
Make an unrecognised scope resolve to no requirement rather than an error. A mistyped attribute should leave the page working rather than break it. That sounds like a small kindness and is really a blast-radius decision: this component sits on pages people edit in a builder UI, so typos are not hypothetical.
Two enforcement points, and only one of them is a control
This is the part worth being pedantic about.
The gate component presents the terms and records the decision. It also reports its state, so the surrounding page can respond. That is presentation.
The enforcement is a separate call. The component that actually launches an application, or navigates to the protected area, asks Apex first:
@AuraEnabled
public static ConsentRequirement pending(String scopeKey) {
ConsentRequirement req = ConsentRegistry.resolve(scopeKey);
if (req == null) { return null; }
return hasSigned(req.formId) ? null : req;
}
@AuraEnabled
public static Boolean isOutstanding(String scopeKey) {
return pending(scopeKey) != null;
}
const outstanding = await isOutstanding({ scopeKey: scope });
if (outstanding) {
// present the gate, do not navigate
return;
}
Having pending return null rather than a flag plus a payload is deliberate: the caller cannot accidentally treat "requirement returned" as "already handled", because there is nothing to render when there is nothing to ask.
A reader who wants to skip the dialog can, because it is a browser and they control it. What they cannot skip is the Apex check standing between them and the thing they want, provided you put it there rather than relying on the overlay.
The five details that decide whether this works
Each of these is invisible in design and obvious in production.
1. Verify the wording on submit, not just on render
The user loads the page, reads revision 3, and goes to make coffee. You publish revision 4. They come back and press Accept.
Without a check you have just recorded acceptance of revision 4 by someone who read revision 3. So accept() takes the id of the wording that was actually shown, compares it to what is in force now, and refuses if they differ:
if (req.formTextId != shownFormTextId) {
throw new ConsentSupersededException(SUPERSEDED_MESSAGE);
}
The caller's job is then to present the new wording and ask again. It must not record the decision it was handed, because that decision was made about text this user never read.
2. Match existing consent on the form, not on the wording
This is the subtlest one, and it will not show up until you add a second language.
When you check whether someone has already accepted, it is natural to match their AuthorizationFormConsent against the AuthorizationFormText currently in force. That is wrong. A revision can have several texts, one per locale, and adding a second locale to a revision people have already accepted would make every one of them fail the match and get re-prompted for terms they agreed to months ago.
Match on the parent AuthorizationForm instead, by way of AuthorizationFormText.AuthorizationFormId. The two look interchangeable in a query and are not.
This is worth a test named after the bug rather than after the method, something like aSecondWordingOnTheSameRevisionDoesNotReAsk, which exists purely to fail if someone later "simplifies" the match back to the wording.
3. Blocking the page behind the dialog needs the host
A component cannot make content outside its own shadow root inert. If your gate renders a modal, everything behind it is still focusable and clickable as far as the browser is concerned, which is both a usability problem and, for keyboard and screen reader users, an accessibility one.
The gate therefore emits its open and closed state, and the host page applies the inert attribute or equivalent:
this.dispatchEvent(new CustomEvent("consentgatestate", {
detail: { open: isOpen }
}));
Half of "blocks interaction behind it" lives inside the component and half lives outside it. Design the event as part of the contract rather than bolting it on when someone reports they can tab into the page underneath.
4. Announce the first resolution, even when nothing is outstanding
This one shipped in a real implementation and is worth the space.
If the gate fires its state event only on transitions, then a gate that resolves to "nothing to ask" never fires at all, because it never transitions. Any host that waits to hear from the gate before proceeding will wait forever.
The symptom is horrible: it silently blocked the deep link for every user who had already accepted, which is most of them, while working perfectly for anyone testing with a fresh account. The fix is to always announce the first resolution, open or closed, and use transitions only after that.
The general lesson is that "fire on change" is the right default for a toggle and the wrong default for something another component depends on for a go-ahead.
5. Handle the double submit in Apex
Two tabs, two presses, one row. The browser cannot see the other tab, so the check belongs on the server:
Id alreadyRecorded = 'Signed'.equals(status)
? signedConsentId(req.formId)
: rejectedConsentId(shownFormTextId);
if (alreadyRecorded != null) { return alreadyRecorded; }
Return the existing id rather than throwing, so a duplicate press is idempotent instead of an error the user has to interpret.
Two more things on the write itself:
Database.insert(consent, AccessLevel.USER_MODE);
USER_MODE is load-bearing here rather than decorative, because this is a record about a person written on that person's behalf, and running it in system mode would hide a sharing or field-level security problem until an audit found it. Set OwnerId explicitly too, rather than relying on the default, so the record survives being written from a context whose running user is not the person consenting.
How much of this is config rather than code
This is the question people ask second, right after "why not a Login Flow", and the answer is most of it. Nothing a legal or operations team routinely changes needs a deployment.
| Change | Where it lives | Deployment? |
|---|---|---|
| Edit the terms wording | AuthorizationFormText record |
No, it is data |
| Publish a new revision and re-ask everyone | New AuthorizationForm plus text |
No |
| Add a language | Another AuthorizationFormText on the same form |
No, and nobody is re-asked |
| Schedule terms to start or retire on a date | EffectiveFromDate, EffectiveToDate |
No |
| Point an application at different terms | The form name on its custom metadata record | Editable in Setup |
| Onboard an application with its own terms | One custom metadata record | Editable in Setup |
| Put the gate on another page, or change its scope | Experience Builder | No |
| Change how the gate behaves | The component and Apex | Yes |
| Add a field to the metadata types | Custom metadata type | Yes |
The split falls this way because of the decision to use the standard Consent objects. AuthorizationForm carries its revision number and effective dates as record data, so "which wording is in force today" is a query the code already runs. A revision published on a Tuesday takes effect on Tuesday without anyone deploying, and everyone is re-asked automatically because existing consent is matched on the form. The same mechanism is why adding a locale is free: it is a second text on a form people have already accepted, so the match still succeeds.
The custom metadata layer does the rest. Those records hold form names rather than wording, so the code never knows which terms exist. That indirection is what lets an admin repoint a scope without an Apex change.
Three caveats, because the table above is the optimistic reading.
Custom metadata edited in Setup does not flow back to source control. Anyone changing a form name in production creates drift against the repository, and the next deployment from source can quietly revert it. Decide who edits where, or retrieve periodically, or the config surface stops being trustworthy.
Experience Builder placement is site state, and Experience Cloud sites are awkward to promote. Dropping the component on a page and setting its scope is configuration, but moving that placement between environments is not always the clean metadata operation you would want. Expect some of it to be a hand step per environment.
No deployment is not the same as no review. Terms wording is a legal artefact and an effective date is a compliance control. Making those easy to change without a release is correct, and it means the governance has to live somewhere other than the deployment pipeline, because the pipeline is no longer standing in the way.
What this costs you
Honesty about the trade, since the alternative was declarative.
You now own a component, an Apex service, a registry class and their tests, where a Login Flow would have been configuration. That is real maintenance. You also have to think about the failure mode of the check itself: if Apex throws, does your launcher fail open or closed? Fail closed, and say why in the message, because a consent gate that fails open is worse than no gate at all since it reports success.
What you get is a control that runs on every route into the functionality, a consent record that answers "who accepted what wording when" without custom versioning logic, and a single component you can drop on any page at any tier.
Frequently Asked Questions
Q: Can I just use a Login Flow if I only have one site?
A: Yes, and you should, because it is less to own. The pattern here earns its complexity when users can arrive at a site from another site in the same org, because that arrival is a session hand-off rather than a login and a Login Flow will not fire for it. If you have one site and direct entry only, the native mechanism is the right answer.
Q: Why not a custom object for acceptance records?
A: Because you would be rebuilding revisioning, effective dating and locale handling that AuthorizationForm and AuthorizationFormText already provide, and you would rebuild them after the first revision ships rather than before. The standard objects also make the data recognisable to anyone auditing it later, which matters more than it sounds when the question is asked by someone outside your team.
Q: Is the modal enough to stop someone using the site?
A: No, and this is the most common mistake in this whole pattern. A modal is a picture of a gate. Anyone can dismiss it from the console, and more importantly anyone who reaches the page by a route your component is not on never sees it. The enforcement is the server-side check called by whatever performs the action. Treat the dialog as the way you ask, and the Apex call as the reason they cannot proceed without answering.
Q: How do I avoid re-prompting everyone when terms change slightly?
A: Decide whether the change is a new revision or new wording on the existing revision, and let the data model carry that decision. A new AuthorizationForm revision means everyone is asked again, which is correct for a material change. A second or amended AuthorizationFormText on the same revision, such as adding a locale, must not re-ask, which is why existing consent is matched on the form rather than the text.
Q: Can our legal team change the terms without a release?
A: Yes, and that is the main reason to use the standard Consent objects. The wording lives in AuthorizationFormText records and the revision and effective dates live on AuthorizationForm, so publishing new terms is a data change rather than a deployment. Everyone is re-asked automatically because existing consent is matched on the form. What still needs a release is changing how the gate behaves, not what it says. The trade is that your deployment pipeline is no longer the thing gating a legal artefact, so the review has to live somewhere else.
Q: What should happen if the user declines?
A: Record it as a Rejected status rather than writing nothing, because "declined" and "never asked" are different facts and you will need to distinguish them. Then decide deliberately what the site does next. Sending them back to a page explaining what they cannot access is usually better than leaving them on a dead screen, and it gives them a route to change their mind.
Key Takeaways
- Gate at the action rather than the login, because the action is the one thing every route has in common.
- The dialog is presentation and the Apex call is the control. Confusing the two produces something that looks enforced and is not.
- The standard Consent objects give you revisioning, effective dates and locale without custom logic, and make the records legible to auditors.
- One component with a scope property beats one component per tier of terms.
- Verify the wording on submit, or you will record consent to text the user never read.
- Match existing consent on the parent form. The day you match the wording instead is the day a second locale re-prompts everyone.
- Always announce the first resolution. A gate with nothing to ask never transitions, and anything waiting on it waits forever.
What's Next?
Recommended Reading:
- An Experience Cloud tile launch is a session hand-off, not a login for why the native mechanism does not fire here
- SAME_ORG_SSO: why one Experience Cloud site cannot be an identity provider for another for the federation route and why it is closed
- Salesforce guest user exposure graded by real reachability for checking what an unauthenticated visitor can reach
- Why your developers do not need Modify All Data for the permission thinking behind running in user mode
Action Items:
- Find every control in your org that depends on a login event firing, and check whether the users it protects arrive by a route that produces one.
- If you already have a consent implementation on a custom object, check what happens when you add a second locale before you need to add one.
- Decide now whether your launcher fails open or closed when the consent check throws, and write the test that pins it.
- Name the test that protects the form-versus-wording match after the bug, so the next person cannot simplify it away without the failure explaining itself.
Resources & References
- AuthorizationForm object reference (Salesforce Developers)
- AuthorizationFormConsent object reference (Salesforce Developers)
- AuthorizationFormText object reference (Salesforce Developers)
- Configure components for Experience Builder and App Builder (Salesforce Developers)
- LWC configuration file reference: targets and targetConfigs (Salesforce Developers)
- Enforce user mode for database operations (Salesforce Developers)
Responses
Checking your session.
Loading responses.