Wiztchta
Drop-in, pluggable CAPTCHA for Phoenix forms and LiveViews. No database, no third-party service.
<.form for={@form} phx-submit="save">
<.input field={@form[:email]} />
<.wiztchta challenge={@challenge} />
<.button>Sign up</.button>
</.form>
- Proof-of-work (default): the browser solves a small SHA-256 puzzle in
about a second, invisibly. Math: "What is 3 + 4?", which works without JS.
You can also add your own type with the
Wiztchta.Challengebehaviour. - Stateless: each challenge travels as an encrypted, expiring token.
- Replay protection: each token can only be used once. Used tokens are
remembered in ETS by default, or in your own
Wiztchta.Store.
Installation
# mix.exs
{:wiztchta, "~> 0.1"}
# config/config.exs
config :wiztchta, endpoint: MyAppWeb.Endpoint
# lib/my_app_web.ex, inside html_helpers
import Wiztchta.Components
// assets/js/app.js (Phoenix's esbuild config already resolves packages in deps/)
import { Hooks as WiztchtaHooks, init as initWiztchta } from "wiztchta";
const liveSocket = new LiveSocket("/live", Socket, {
hooks: { ...colocatedHooks, ...WiztchtaHooks },
// ...
});
initWiztchta(); // only needed for forms rendered by controllers
Proof-of-work uses crypto.subtle, so it needs HTTPS (or localhost).
LiveView
def mount(_params, _session, socket) do
{:ok, assign(socket, form: to_form(%{}), challenge: Wiztchta.new())}
end
def handle_event("save", params, socket) do
socket = assign(socket, :challenge, Wiztchta.new()) # tokens are single-use
case Wiztchta.verify(params) do
:ok -> {:noreply, ...}
{:error, reason} -> {:noreply, put_flash(socket, :error, Wiztchta.error_message(reason))}
end
end
Keep the challenge in an assign. Without challenge={...}, the component makes
a new challenge each time it renders.
Controllers
<.form for={@form} action={~p"/contact"}>
...
<.wiztchta type={Wiztchta.Challenges.Math} />
</.form>
plug Wiztchta.Plug when action in [:create]
def create(conn, params) do
case conn.assigns.wiztchta do
:ok -> ...
{:error, reason} -> ...
end
end
Configuration
config :wiztchta,
endpoint: MyAppWeb.Endpoint, # or secret_key_base: "..."
default_challenge: Wiztchta.Challenges.ProofOfWork,
pow_difficulty: 16, # each +1 doubles the work
max_age: 300, # seconds a challenge stays valid
store: Wiztchta.Store.ETS, # nil disables replay protection
param: "wiztchta" # form param name
You can pass any of these per call too, for example
Wiztchta.new(type: Wiztchta.Challenges.Math, max_age: 60) or
<.wiztchta opts={[difficulty: 18]} />.
The default ETS store only knows about tokens used on its own node. In a cluster, a token could be
used once on each node. Implement Wiztchta.Store on top of shared storage to
prevent that.
Styling
The markup uses plain classes (wiztchta, wiztchta-pow, wiztchta-status,
wiztchta-math, wiztchta-label, wiztchta-input), and proof-of-work exposes
data-state="pending|solving|solved|error". Pass class to the component to
add your own.
Development
mix deps.get
mix test
mix dev # demo app at http://localhost:4000 (LiveView + controller forms)
mix precommit
License
MIT. See LICENSE.