Skip to content
Antisy
Documentation · Browser script

The browser script

One tag in the page: the Antisy tag loads the browser script. When a POST form is sent to your site, the script fills in the token field itself. The visitor sees nothing: no box to tick, no puzzle.

The Antisy tag

In the <head> of the page
<script src="https://api.antisy.com/v1/i/l.js"
        data-site="VOTRE_CLE_DE_SITE" defer></script>

  • data-site is the site key, public. Never a secret. Keys have no prefix: nothing in the string says what it is. Count the characters before pasting (32 for the site key, 64 for a secret).
  • A classic script: no type="module". defer or async both suit. Place it as early as possible in the <head>, once per page.
  • Load it from api.antisy.com, without copying it to your own server: its content changes with each release.
  • No integrity attribute. A fixed hash would block the script at the next release, and so all your visitors.
  • The script itself puts a hash on the tag of the measurement script it creates: your browser runs it only if it receives exactly the bytes served by Antisy. Nothing to add to your CSP policy for this.

What the browser script does

When it loads, the script installs window.antisy and a submit listener on the window, then prepares its resources while the visitor fills in the form. It also counts the interactions with the page (page on data). As soon as the visitor enters a form (a click or a keystroke in a field, keyboard focus), it asks for an attempt token of its own accord and starts a short computation on the device in the background, without showing anything. Its preparation listeners are passive: they change neither the focus nor the behaviour of the page.

When a covered form is sent (next section), the script:

  1. holds the submission while it obtains the token: your handlers do not see the event yet;
  2. measures the browser and deposits the measurement with Antisy;
  3. puts the token in the hidden antisy-token field: it creates the field if it does not exist, and fills in all those that exist;
  4. resumes the submission with requestSubmit: your handlers see a single submit event, with the field already filled in.

The wait lasts 6 seconds at most. If Antisy does not answer or the deadline is exceeded, the form is sent anyway, without a token: the antisy-token field is missing, or empty if it already existed in the form. The next page says what happens then. Forms added to the page after it has loaded are covered in the same way. Your server reads the antisy-token field after parsing the body, with an empty default value, and has it verified (page on verification).

Which forms are covered

By default, a form whose method is POST and whose submission address is that of the page: the same origin, that is the same scheme, the same host name and the same port. The method is that of the submit button if it carries one (formmethod), otherwise that of the form. The address is the formaction of the button if there is one, otherwise the action of the form; empty, it is the page itself.

  • A subdomain is another origin: a form on www.exemple.ch that sends to api.exemple.ch is not covered as long as it does not carry data-antisy. No token goes to a third party by accident.
  • A search form (role="search", a type="search" field) sent by POST to your site is covered like the others, but the script does not prepare its token in advance: it asks for it on submission. By GET, it is not covered, with nothing to write.
  • A method="dialog" form never is.

Four attributes, all optional, go on the <form>:

data-antisy="off"
Excludes the form: the browser sends it as it is, with no wait, no field, no token. The value is read regardless of case and whitespace. It wins over any other attribute.
data-antisy

Covers a form that the script would otherwise let through: a GET, or a POST to another origin.

For a GET, the token goes in the address: logs of the server and of intermediaries, browser history, Referer header. It is single-use and expires quickly, but a reload replays it and the verification refuses it. Keep it for a GET that protects something, never for a search.

data-antisy-action="contact"
Names the action deposited with the token, returned as it is by the verification: 1 to 32 characters among a-z, 0-9, _ and -, in lower case. Never personal data. Without this attribute, the deposit is sent without an action; an invalid name is ignored.
data-antisy-ignore
The former name of the exclusion, with the same effect as data-antisy="off": its presence is enough, whatever its value.

What the script does not cover

When in doubt, the script does not hold the submission: better a form without a token than a broken form. These submissions are sent as they are, without a token; the page that wants to protect them calls antisy.token() (next section).

  • A form whose target opens another window or a named frame (target, formtarget or <base target> other than empty or _self; _top and _parent are worth _self in a top-level page). Holding the submission for several seconds would make the visitor’s activation expire: the browser would refuse to open the window, without a message.
  • A submission by form.submit(), which fires no submit event (likewise jQuery’s $(form).submit()). form.requestSubmit() fires one and is covered.
  • A submission that goes through no submit event: a type="button" button whose handler reads the fields, a fetch or an XHR started by another event.
  • A GET or a POST to another origin not marked data-antisy.
  • The forms of a web component that isolates its DOM (the submit event does not leave it) and those of an <iframe>: one tag per document.
  • A browser without requestSubmit (not verified: Safari before 16).

What can be lost when the script holds the submission

  • A form that your page removes from the DOM in the fraction of a second after the click is no longer sent: the submission is lost, without a message. Set data-antisy="off" and call antisy.token() yourself.
  • A submit listener that your page puts on the window, in the capture phase, before the tag, sees the held event and then the resumed event: a submission counter counts two submissions, and a guard against double submission that cancels the second would cancel the resumed submission. Put it on document or on the form.
  • If your code compares event.submitter with its button: when the button has been removed, or disabled by a <fieldset>, during the wait, the script resumes the submission through a hidden temporary button, and that is the one you see.
  • A token is used only once. After a submission that does not leave the page (your validation cancels it, a fetch takes over the submission), the next submission made by a gesture of the visitor obtains a new token. A retry that your script starts of its own accord, without a gesture, goes off with a token already verified and will be refused: ask antisy.token() for a new token.
  • If the Antisy API does not answer, each submission is held for up to 6 seconds before being sent without a token. For another trade-off, exclude the form and call antisy.token() with your own deadline (totalTimeoutMs). A short deadline leaves less time for the background computation: a device that does not finish it then receives a token that the verification refuses, and not null.

Asking for a token yourself: antisy.token()

For a submission that the script does not cover: fetch or XHR, single-page application, form.submit(), a form not covered. The page calls window.antisy.token(options), which returns a promise: the token, or null. It never throws, and settles after 5 seconds at the latest by default.

action
A label for the action: contact, newsletter… 1 to 32 characters among a-z, 0-9, _ and -. Antisy returns it as it is in the response of the verification. Never personal data. A label that does not follow this format makes the deposit fail: token() returns null.
totalTimeoutMs

Deadline of the whole call, in milliseconds. By default 5000. At the deadline, null.

The deposit keeps 1 second of this deadline for itself: the background computation stops at the latest 1 second before the deadline. If it is not finished, the token is sent without its solution and the verification refuses it, whatever the mode. The shorter the deadline, the more a slow device thus obtains a refused token rather than null.

The token goes in the x-antisy-token header of your request, or in the antisy-token field of the form. The name of the field and that of the header are part of the contract.

Single-page application
async function sendContact(fields) {
  // Explicit mode, for a submission by fetch that the script does not cover: your page asks for the token.
  // Returns after 5 s at the latest, and never throws.
  const token = window.antisy ? await window.antisy.token({ action: "contact" }) : null;
  return fetch("/api/contact", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      ...(token ? { "x-antisy-token": token } : {}),
    },
    body: JSON.stringify(fields),
  });
}

If the page calls an origin other than its own (www. to api.), your server’s response to the CORS preflight check must allow the header: Access-Control-Allow-Headers: content-type, x-antisy-token. Without it, the browser refuses the call itself.

A covered form sent by fetch

A POST form to your site that your page sends by fetch from a submit handler is covered too: the script fills in the field before your handler, which can read it in new FormData(form). If you set the header yourself, add data-antisy="off" to the form, so as to have only one deposit per submission.

If your page already called token() for a covered form

Previous versions of this documentation asked for this code. It still works, but it is no longer needed for a POST form to your site. Called during the dispatch of the resumed submit event, with the same action as the form (data-antisy-action, or neither), it receives the token that the script has just deposited. Otherwise, it makes a second deposit: two deposits for the first submission of the page, which becomes slower. To migrate, remove the call; or give the form a data-antisy-action with the same name as the call; or set data-antisy="off" if you keep control. A form whose page called token() during the dispatch of its event is driven by the page: its following submissions are no longer held by the script, and a submission whose field your handler does not fill in is sent without a token.

Excluding a form (optional)

The script prepares the token as soon as the visitor enters a form of the page, and holds the submission of every covered form. A form that you do not protect (a newsletter present on every page, a logout, a search by POST) would trigger it for every visitor who stops on it, and every token prepared counts towards the issuing rate of your tier. Set data-antisy="off" on that <form>:

An excluded form
<form action="/newsletter" method="post" data-antisy="off">
  <input name="email" type="email">
  <button>S'abonner</button>
</form>

  • The attribute excludes the form from the preparation as well as from the resumption: no wait, no field, no token. The fields and buttons attached to this form from outside by form="id" are excluded too.
  • Set afterwards by a script on a form that the script has already filled in, it leaves in the form the antisy-token field with its last token, already used: the next submission would carry it and be refused. Remove that field at the same time.
  • It does not prevent calling antisy.token() from this form: the token is then requested at the call.
  • A form to another origin, not marked, is not held and receives no field. But the preparation (an attempt token, a round of computation) can start on contact with one of its fields: it serves the page that calls antisy.token() for that form, and the script cannot know whether that is your case. Set the attribute if you do not use it.

CSP policy

If your site sends a Content-Security-Policy, add these sources to those it already carries ('self' stands for yours):

HTTP header
Content-Security-Policy: script-src 'self' https://api.antisy.com 'wasm-unsafe-eval'; connect-src 'self' https://api.antisy.com; worker-src 'self' blob:

script-src https://api.antisy.com

The tag of the script, and the tag of the measurement script that the script then creates: the same host. That second tag carries a hash that the script sets itself: no directive needs to be added for that hash.

Missing: no window.antisy, or no measurement, so no token.

script-src 'wasm-unsafe-eval'

The compilation of a WebAssembly module.

Missing: token() returns null. The browser reports the violation (wasm-eval); the page throws no exception.

connect-src https://api.antisy.com

The calls the script makes to the API: its description, its module, the attempt token and the deposit. The measurement script is no longer one of them: a tag falls under script-src.

Missing: token() returns null.

worker-src blob: (optional)

The background computation runs in a Worker created from a blob:. You can add worker-src 'self' blob: to your policy.

Missing, nothing breaks: the computation then runs on the main thread of the page, in small slices, with the same difficulty and the same result. A policy that does not list blob: gives this fallback, not a failure.

'unsafe-eval' is not required

The script is served as it is and has no text evaluated as code in your page. A policy that you had widened for a previous version of the script can remove this source: only 'wasm-unsafe-eval' remains, for the WebAssembly module.

CSP policies with a nonce per response

Three variants work, without 'unsafe-inline' (<n> is the nonce of the response). In all three cases, connect-src and worker-src remain those of the block above.

List of hosts and nonce
script-src
script-src 'self' https://api.antisy.com 'nonce-<n>' 'wasm-unsafe-eval'

Nonce and 'strict-dynamic'
script-src
script-src 'nonce-<n>' 'strict-dynamic' 'wasm-unsafe-eval'

Without a list of hosts: the nonce of the tag of the script decides. The tag carries it, and the measurement script it creates is admitted by 'strict-dynamic'.

The tag, with the nonce of the page
<script nonce="<n>" src="https://api.antisy.com/v1/i/l.js"
        data-site="VOTRE_CLE_DE_SITE" defer></script>

Nonce alone
script-src
script-src 'nonce-<n>' 'wasm-unsafe-eval'

Without a list of hosts and without 'strict-dynamic': the script copies the nonce of its own tag onto that of the measurement script, which is therefore admitted.

A nonce-based policy that contains 'strict-dynamic' ignores the list of hosts of script-src (CSP level 3): the nonce of the tag of the script decides.

  • Try the policy first as Content-Security-Policy-Report-Only: the violations reported name the directive to complete.
  • A policy that has only default-src: add the directives above explicitly rather than widening default-src.
  • Trusted Types. The script creates a policy named antisy, which the page must allow if it requires require-trusted-types-for 'script': add antisy to your trusted-types directive, next to your own policies. It serves only to approve the addresses the script asks it for, and your scripts cannot use it. If the page loads the tag twice, add 'allow-duplicates'.
    HTTP header
    Content-Security-Policy: script-src 'self' https://api.antisy.com 'wasm-unsafe-eval'; connect-src 'self' https://api.antisy.com; worker-src 'self' blob:; require-trusted-types-for 'script'; trusted-types antisy

    Verified in Chrome; not verified in Firefox and Safari. Without antisy in your list, the browser refuses the tag of the measurement script, token() returns null and the violation is reported; a default policy of your page that admits the address catches this case. A page that requires nothing but whose policy already lists names without antisy sees the script work, with a violation reported on every page view.

Declared host names

Antisy issues a valid token only to pages whose host name is exactly among those of the site. They are declared in the “Paramètres” (settings) tab of the site, in the dashboard, which is in French: up to 20 names, the name alone, without https://, without a port, without a path, without a wildcard. Declare them before putting the tag in place, not only before turning collection on.

  • www.exemple.ch and exemple.ch are two names. Declare each of those that carry a form.
  • From a name not declared, token() still returns a string that looks like a token: the browser learns nothing. It is the verification that refuses it (success is false), whether collection is on or off: as soon as the tag is in place, every token coming from a page served under that name is refused.
  • To develop locally, put a dummy site key in data-site instead (test keys): it has no host names, the local page receives the fixed token of that key, which your server verifies with a test secret. Your production site thus keeps its list of names, without localhost: a declared name holds for every page served under that name, and localhost means everyone’s machine, not only yours.
  • The hostname field of the response carries the name of the page only if the browser supplied a usable origin. From a sandbox iframe, the origin is null: the token is indeed issued, but hostname is null.

HTTPS and browsers

The script needs a secure context: HTTPS, or localhost in development. On a page served over HTTP, token() returns null and the script no longer holds any submission, except the very first if it is sent before the measurement script has loaded: that one can still be held for a short moment, then it is sent without a token.

It seals its measurement with the X25519 algorithm. When the browser does not have it in Web Crypto, the script does the computation itself: a fallback written by Antisy, which produces the same envelope and follows the same rules, and which requires BigInt. A browser that has neither X25519 nor BigInt obtains no token.

  • Measured by Antisy, on the whole path (token, then server-side verification): Chrome, Firefox (version not recorded) and Safari 17.5 on macOS 14.5. Safari 17.5 does not have X25519 in Web Crypto (measured on 4 October 2026): it goes through the fallback.
  • Not measured: iPhone and iPad, the other versions of Safari and Firefox, Edge, phone browsers. According to MDN (consulted by Antisy on 23 September 2026, not checked again), Chrome before version 133 and Firefox before 130 do not have X25519: the fallback applies to them as to Safari, but none of these versions has been measured.

If the browser script does not run

A CSP policy that refuses script-src for the host of the API or 'wasm-unsafe-eval', or connect-src for the API, an ad or tracker blocker, a page served over HTTP, a browser without X25519 or BigInt, an API that cannot be reached from the browser, an intermediary that modifies scripts on the way (a corporate proxy that injects code into them, for example: the hash of the measurement script no longer matches, the browser refuses to run it and token() returns null), a visitor without JavaScript, a form that the script does not cover and whose page does not call token() (form.submit(), fetch, a target in another window: see “Which forms are covered”): in all these cases, the form is sent without a token: the antisy-token field is missing from the body, or empty if it already existed in the form. When the script has held the submission and the token fails or exceeds 6 seconds, it is sent in the same way. So read the field with an empty default value, without requiring it to be there: your server then calls the verification with an empty token.

What follows depends on the “Collecte du navigateur” (browser collection) setting, in the “Paramètres” (settings) tab of the site:

Collection on

A submission without a token does not get through, in any verification mode: verdict deny, success at false, error-codes at ["missing-input-response"]. The visitor does not know it; your server does.

An incomplete CSP policy, or a form not covered whose page forgets token(), therefore has all your visitors refused, humans included.

Collection off

A missing token is not held against the visitor: the verdict follows the rest of the assessment, and returns allow if nothing else holds the visitor back. A token that is there but invalid, replayed, expired or from another site is refused, whether collection is on or off. A new site starts with collection off.

A token that is there is not always accepted: a device that does not finish the background computation in time (hidden tab, slow device) sends without the solution, and the attempt is refused like an invalid token, whatever the mode, without the response giving the cause. Plan the same message to the visitor for every refusal (“try again”): a new attempt starts a new computation with a new token.

Before turning collection on

  1. The tag is in the <head> of every page that carries a form, over HTTPS, and the CSP allows the script and the calls to Antisy.
  2. Each host name of these pages is declared on the site: that had to be done before putting the tag in place (declared host names).
  3. Each POST form to your site is covered as it is, or excluded knowingly (data-antisy="off"); a form to another origin, subdomain included, that you want to protect carries data-antisy.
  4. Each submission that the script does not cover (fetch, form.submit(), a target in another window) calls antisy.token() and puts the token in antisy-token or x-antisy-token.
  5. Your server reads this token and verifies it, letting the submission through if the API cannot be reached.

If your visitors are wrongly refused, check the declared host names first. Turning collection off from the dashboard only catches the submissions without a token, and changes nothing else: a refusal with a token present (host name not declared, computation unfinished on a slow device) does not depend on it.