Security
Two things decide whether a WF-Algolia site is safe: which key you publish, and what happens to record content on its way into the page. The script handles the second. The first is on you.
Keys
Everything in a script tag is public. View source is enough.
| Key | Safe in a script tag | Can do |
|---|---|---|
| Search-Only | Yes | Query indices |
| Admin | Never | Add, overwrite and delete records and indices |
Use a Search-Only key with the search ACL and nothing more. If an Admin key has
ever been pasted into custom code, treat it as compromised and rotate it in the
Algolia dashboard — republishing the site does not undo it having been public.
For the same reason, WF-Algolia's own sync stores your Admin key server-side and never sends it to the browser.
Rendering untrusted content
Anything in your index is untrusted as far as the script is concerned — CMS content passes through editors, imports and APIs, and any of those can carry markup.
Plain text bindings
wf-algolia-text writes through textContent. Markup in the field is displayed
as characters, never parsed. This is the default and the safe one.
HTML bindings
wf-algolia-html exists for fields that legitimately contain rich text. It is
sanitized before insertion, using the browser's own parser plus a strict
allow-list.
Only these tags survive:
p · br · em · strong · b · i · u · mark · ul · ol · li ·
h1 · h2 · h3 · h4 · blockquote · code · pre · a · img
Any other element is unwrapped: the tag is dropped and its text is kept. A
<script> in a field becomes its own text content, not an executing script.
Attributes work the same way — an allow-list, not a blocklist:
| Tag | Attributes kept |
|---|---|
a | href, title, target, rel |
img | src, alt, title, width, height |
| everything else | none |
This is why onclick, onerror and style cannot get through. They are not
blocked by name — they are simply not on the list, so nothing has to anticipate
the next event-handler attribute someone invents.
URLs
Every URL-bearing attribute — a href, img src — is additionally checked.
The value is parsed with the browser's own URL parser and its scheme is matched
against an allow-list:
| Attribute | Schemes allowed |
|---|---|
a href | https mailto tel |
img src | https |
Anything else is replaced with # (or an empty string, for src) and logged:
[wf-algolia] Blocked unsafe URL scheme: javascript: javascript:alert(1)
http addresses are refused. A cleartext destination is a downgrade your
visitor never sees, so a record field holding http://example.com/page renders
as a dead link and logs:
[wf-algolia] Blocked a cleartext destination: http://example.com/page — destinations must be https. Update the record field to an https address.
The fix is in the record, not in your markup: change the stored address to
https. A Webflow published site is always served over https, so this only
affects addresses your own content points at.
One narrow exception exists for local development. If the page itself is served
over http (a local server, for example), an http address on that same origin
is allowed, because there is no downgrade to protect against on a page that is
already cleartext. This can never apply on a published Webflow site.
An allow-list rather than a blocklist, because a blocklist of javascript:,
data: and vbscript: prefixes was bypassable: the URL spec strips tab,
newline and carriage return from inside a scheme, so java\nscript:alert(1)
resolved as javascript: while no prefix comparison could see it. Parsing
first makes that entire class of trick structurally impossible instead of
merely enumerable.
Relative URLs keep working and are never rewritten — the original value is
returned, not the resolved absolute one, so /blog/post stays /blog/post.
Seeing that warning means a record in your index contains an attack payload, not that your markup is wrong. It is worth finding out how it got there.
Forms the script takes over
A search element often sits inside a Webflow Form Block. Left alone, pressing Enter submits the form, reloads the page and throws the query away. The script can prevent that: it stops the submit, hides the submit button, and hides the success and error messages.
It does this only when you ask. Mark the form:
<form wf-algolia-form="search"> ... </form>
Only that exact value works. Anything else, including true, leaves the form
alone.
An unmarked form is never touched, and says so once in the browser console:
[wf-algolia] Not taking over this form: it has no wf-algolia-form="search". The form will submit normally, which reloads the page and discards the query. If it is a search form, add wf-algolia-form="search" to it.
Upgrading from 1.0.8 or earlier
Older versions took over any form containing a search element, and refused only forms carrying a password or payment field. That was wrong for the forms in between: a contact or newsletter form carries nothing sensitive, so it was taken over and silently stopped submitting.
If you built a search form inside a Form Block by hand, add
wf-algolia-form="search" to it and republish. Until you do, it will submit and
reload the page.
Anything the app inserted for you is unaffected: inserted experiences are not
built inside a <form>.
Privacy
Insights tracking is off unless you set data-insights="true".
Turning it on is not enough to start it. The script binds its listeners and
then sends nothing until your consent surface calls
WfAlgolia.setInsightsConsent(true), and anything the visitor did before that
is discarded rather than sent afterwards. Add data-insights-consent="granted"
if your site already has a legal basis to track. See the
script tag reference for both attributes.
When on, it is cookieless by default — the user token lives only for the page
session. Setting data-insights-cookie="true" persists it across visits, which
is what makes personalization work and what may put it in scope for your cookie
consent flow. That cookie is written SameSite=Lax, and Secure on https.
wf-algolia-user-token="cookie:NAME" reads only _ALGOLIA or a cookie named
wf-algolia-* / wf_algolia_*. Any other name is refused and logged, so the
attribute cannot be used to read an unrelated cookie.
Personalized recommendations
The recommended-for-you model is the one feature that reads a visitor
identifier in order to work. It waits for consent exactly like event tracking
does.
What is collected, and why:
| Question | Answer |
|---|---|
| What is read | An Algolia visitor identifier, from the _ALGOLIA cookie or a wf-algolia-* cookie you set yourself |
| Who reads it | The script, in the visitor's browser |
| Where it is sent | Your own Algolia application, in the Recommend request |
| What it is used for | Choosing which of your records to recommend to that visitor |
| Who else receives it | Nobody. It is not sent to us, and we never see it |
| Before consent | Nothing is read and no request is made |
Until consent is given, the section stays hidden and logs a note naming the two ways to grant it. The moment consent arrives the section loads, so a cookie banner that grants consent after the page has loaded works without a reload.
This applies whether or not you enabled data-insights. Personalization and
event tracking are separate features and either can be used without the other,
but both wait for the same consent.
Content Security Policy
The script needs very little relaxation of its own.
- It runs no
evaland builds no code from strings, so'unsafe-eval'is not required. - It assigns no HTML strings and writes no
styleattributes, so'unsafe-inline'is not required — for scripts or for styles.
The minimum policy is the CDN it loads from, the Algolia endpoints it calls, and wherever your record images live:
script-src https://cdn.jsdelivr.net;
connect-src https://*.algolia.net https://*.algolianet.com;
img-src https://cdn.prod.website-files.com;
| Directive | Why |
|---|---|
script-src | Where the script itself is served. Change it if you host the file yourself. |
connect-src | Algolia resolves queries across *.algolia.net and its *.algolianet.com retry hosts. Both are needed. Add https://insights.algolia.io only if you have enabled Insights. |
img-src | Wherever the images in your records are hosted. For images uploaded to Webflow that is https://cdn.prod.website-files.com; if your records point at another CDN, list that instead. |
style-src | Nothing to add. The script's own rules are installed as a constructable stylesheet adopted into the document, which style-src does not govern. |
How results are rendered
Search results are built as DOM nodes and inserted with replaceChildren —
no HTML string is ever assigned to innerHTML. Highlighted matches and
snippets are assembled from text nodes plus one element per match, so record
content can only ever become text, never a tag name and never an attribute.
Visibility is driven by two classes rather than by writing element.style.
These are CSS classes, not attributes, which is why they are not in the
attribute reference:
| Class | Meaning |
|---|---|
.wf-algolia-hidden | Hidden. display: none !important. |
.wf-algolia-display-<kind> | Shown, with the display your wf-algolia-display asked for — block, flex, grid, inline, inline-block, inline-flex, inline-grid, contents, flow-root. |
You can style against these, but you do not have to define them: the script installs the rules itself.
Trusted Types
If you send:
Content-Security-Policy: require-trusted-types-for 'script'
everything works with no further action. An absent trusted-types directive
leaves policy creation unrestricted, and the only sink the script touches is
the HTML parser used for wf-algolia-html rich-text fields.
If you enumerate the policies your page allows, add ours:
Content-Security-Policy: require-trusted-types-for 'script';
trusted-types wf-algolia
If you do not — or you send trusted-types 'none' — the script keeps working
and only rich-text fields are affected: wf-algolia-html renders its field as
plain text instead of formatted markup, and logs one line saying so. Search
results, highlighting and snippets are unaffected, because none of them go
near an HTML parser.
One caveat
On browsers without constructable stylesheets — before Chrome 73, Safari 16.4
or Firefox 101 — the script falls back to injecting a <style> element, which
style-src does govern. On such a browser under style-src 'self', results
would render but would not hide correctly. In practice those browsers predate
strict-CSP adoption and the two do not overlap, but we would rather state the
limit than imply there isn't one.
Reporting a vulnerability
Please report security issues privately rather than in a public issue.