Phoenix integration

Copy Markdown View Source

This guide wires the ALTCHA widget into a Phoenix app: load the widget script, serve proof-of-work challenges with Altcha.Plug.Challenge, and verify the submitted solution on the server.

It uses the v2 API (Altcha.V2) — the key-derivation proof-of-work introduced with widget v3. Widget v1 and v2 use the older hash-based challenge; the same shape applies with Altcha.V1, which widget v3 still accepts.

1. Install

# mix.exs
defp deps do
  [
    {:altcha, "~> 2.0"}
  ]
end

Altcha.Plug.Challenge needs :plug, which Phoenix already brings in. It is an optional dependency of :altcha, so nothing is pulled in for non-Phoenix users.

Put the signing secret in the environment and read it in config/runtime.exs:

# config/runtime.exs
config :altcha, Altcha.Plug.Challenge,
  hmac_signature_secret: System.fetch_env!("ALTCHA_HMAC_SECRET")

Generate a secret with mix phx.gen.secret (or any 32+ byte random string).

2. Load the widget script

Pick one of the following. A pinned CDN tag is the simplest and matches ALTCHA's own documentation.

Option A - CDN

Add to your root layout, pinning the version. Use the dist/main build - it bundles the styles and the PBKDF2/SHA workers into one file. (dist/external splits the CSS out and drops the workers; only reach for it under a strict CSP.)

<script
  type="module"
  src="https://cdn.jsdelivr.net/npm/altcha@3.2.2/dist/main/altcha.min.js"
  async
  defer
></script>

Option B - npm

If your app has an assets/package.json:

npm install altcha --prefix assets
// assets/js/app.js
import "altcha";

Option C - vendored file

Fetch the script once, commit it, and import it locally (this is how Phoenix vendors topbar.js):

curl -L https://cdn.jsdelivr.net/npm/altcha@3.2.2/dist/main/altcha.min.js \
  -o assets/vendor/altcha.js
// assets/js/app.js
import "../vendor/altcha.js";

3. Serve challenges

Mount Altcha.Plug.Challenge in your router. It answers GET with a freshly signed challenge as JSON and ignores every other method.

# lib/my_app_web/router.ex
scope "/altcha" do
  forward "/challenge", Altcha.Plug.Challenge
end

Use a scope without an alias. Phoenix expands the forwarded plug against the scope's alias, so scope "/altcha", MyAppWeb would look for MyAppWeb.Altcha.Plug.Challenge.

Options can be passed inline and override the application config, e.g. forward "/challenge", Altcha.Plug.Challenge, cost: 50_000. See Altcha.Plug.Challenge for the full list (:algorithm, :cost, :expires_in, ...).

Point the widget at that path:

<form phx-submit="submit">
  <div id="altcha" phx-update="ignore">
    <altcha-widget challenge={~p"/altcha/challenge"}></altcha-widget>
  </div>
  <button type="submit">Submit</button>
</form>

The widget renders its own hidden input holding the payload, named altcha by default (change it with the name attribute). Do not add a second input with that name — it would shadow the widget's value in params.

The wrapper carries phx-update="ignore" (which needs an id) so LiveView's DOM patching leaves the widget's own markup alone.

4. LiveView hook (optional)

Nothing above needs JavaScript: phx-submit serialises the form from the DOM, so the widget's own hidden input is submitted like any other field. The same is true for a dead view (a regular controller form).

A hook is only worth adding when you want to react to the widget's state — for example to keep the submit button disabled until verification finishes. The widget emits a statechange event whose detail is { state, payload }:

// assets/js/app.js
let Hooks = {};

Hooks.Altcha = {
  mounted() {
    const widget = this.el.querySelector("altcha-widget");
    const button = this.el.querySelector("button[type=submit]");
    if (!widget || !button) return;

    widget.addEventListener("statechange", ({ detail }) => {
      button.disabled = detail?.state !== "verified";
    });
  },
};

let liveSocket = new LiveSocket("/live", Socket, {
  params: { _csrf_token: csrfToken },
  hooks: Hooks,
});

Add phx-hook="Altcha" to the form's wrapper element. LiveView requires a unique id on any element carrying phx-hook.

5. Verify the solution

On submit, decode the payload and verify it with the same secret used to sign the challenge:

def submit(conn, %{"altcha" => token} = params) do
  secret = Application.fetch_env!(:altcha, Altcha.Plug.Challenge)[:hmac_signature_secret]

  result =
    token
    |> Altcha.V2.decode_payload()
    |> case do
      %Altcha.V2.Payload{} = payload ->
        Altcha.V2.verify_solution(%Altcha.V2.VerifySolutionOptions{
          challenge: payload.challenge,
          solution: payload.solution,
          hmac_signature_secret: secret
        })

      nil ->
        %Altcha.V2.VerifySolutionResult{verified: false}
    end

  if result.verified do
    # ... proceed
  else
    # ... reject: result.expired / result.invalid_signature / result.invalid_solution
  end
end

In LiveView, do the same inside handle_event/3.

Optional: bind the challenge to form fields

Widget v3 configures server-side verification programmatically, not through HTML attributes — verifyUrl plus serverVerificationFields: true makes the widget send the form's text fields to your verification endpoint:

document.querySelector("altcha-widget").configure({
  verifyUrl: "/altcha/verify",
  serverVerificationFields: true,
});

The endpoint answers with a server-signed payload carrying a fieldsHash, which you can check against the submitted form with Altcha.V2.verify_fields_hash/4. (In widget v2 these were the verifyurl and verifyfields attributes.)

Optional: Cloud or Sentinel

When you use the hosted ALTCHA Sentinel endpoint, the submitted token is a server-signed payload. Verify it with Altcha.V2.verify_server_signature/2, which also returns the parsed classification / score data.