Packages
ex_turnstile
0.1.0
Cloudflare Turnstile server-side token verification for Elixir. Built by Shiko (https://shiko.vet).
Current section
Files
Jump to
Current section
Files
lib/ex_turnstile.ex
defmodule ExTurnstile do
@moduledoc """
Cloudflare Turnstile server-side token verification for Elixir.
By [Shiko](https://shiko.vet) — tech@shiko.vet
## Setup
{:ex_turnstile, "~> 0.1"}
config :ex_turnstile, secret_key: System.get_env("TURNSTILE_SECRET_KEY")
## Usage
case ExTurnstile.verify(token) do
:ok -> # Valid
{:error, reason} -> # Invalid
end
ExTurnstile.verify(token, remote_ip: "1.2.3.4")
"""
require Logger
@verify_url "https://challenges.cloudflare.com/turnstile/v0/siteverify"
@doc """
Verifies a Cloudflare Turnstile token.
## Options
* `:remote_ip` - Client IP address (recommended)
* `:secret_key` - Override the configured secret key
## Returns
* `:ok` - Token is valid
* `{:error, reason}` - Token is invalid or verification failed
"""
@spec verify(String.t() | nil, keyword()) :: :ok | {:error, String.t()}
def verify(token, opts \\ [])
def verify(nil, _opts), do: {:error, "Token is required"}
def verify("", _opts), do: {:error, "Token is required"}
def verify(token, opts) when is_binary(token) do
secret_key = opts[:secret_key] || Application.get_env(:ex_turnstile, :secret_key)
if is_nil(secret_key) || secret_key == "" do
{:error, "Turnstile secret_key not configured"}
else
do_verify(token, secret_key, opts)
end
end
defp do_verify(token, secret_key, opts) do
payload = build_payload(token, secret_key, opts[:remote_ip])
case Req.post(@verify_url, form: payload, receive_timeout: 5000) do
{:ok, %Req.Response{status: 200, body: %{"success" => true}}} ->
:ok
{:ok, %Req.Response{status: 200, body: %{"success" => false, "error-codes" => codes}}} ->
{:error, parse_error(codes)}
{:ok, %Req.Response{status: status}} ->
{:error, "Turnstile API returned HTTP #{status}"}
{:error, exception} ->
Logger.error("Turnstile verification failed: #{Exception.message(exception)}")
{:error, "Connection error"}
end
end
defp build_payload(token, secret_key, remote_ip) do
payload = %{secret: secret_key, response: token}
if remote_ip, do: Map.put(payload, :remoteip, remote_ip), else: payload
end
defp parse_error(codes) when is_list(codes) do
cond do
"timeout-or-duplicate" in codes -> "Token expired or already used"
"invalid-input-response" in codes -> "Invalid token"
"invalid-input-secret" in codes -> "Server configuration error"
"missing-input-response" in codes -> "Token is required"
"bad-request" in codes -> "Malformed request"
"internal-error" in codes -> "Turnstile internal error"
true -> "Verification failed: #{Enum.join(codes, ", ")}"
end
end
defp parse_error(_), do: "Unknown verification error"
end