Skip to main content
Anubis supports custom challenge types through the challenge.Impl interface. This allows you to create specialized bot detection mechanisms beyond the built-in proof-of-work, meta-refresh, and Preact challenges.

Challenge Interface

All challenge implementations must satisfy the challenge.Impl interface:
type Impl interface {
	// Setup registers any additional routes with the Impl for assets or API routes.
	Setup(mux *http.ServeMux)

	// Issue a new challenge to the user, called by the Anubis.
	Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *IssueInput) (templ.Component, error)

	// Validate a challenge, making sure that it passes muster.
	Validate(r *http.Request, lg *slog.Logger, in *ValidateInput) error
}
Source: lib/challenge/interface.go:59-68

Input Structures

IssueInput

Provided when issuing a new challenge:
type IssueInput struct {
	Impressum *config.Impressum
	Rule      *policy.Bot
	Challenge *Challenge
	OGTags    map[string]string
	Store     store.Interface
}

ValidateInput

Provided when validating a challenge response:
type ValidateInput struct {
	Rule      *policy.Bot
	Challenge *Challenge
	Store     store.Interface
}

Challenge Metadata

type Challenge struct {
	IssuedAt       time.Time         `json:"issuedAt"`
	Metadata       map[string]string `json:"metadata"`
	ID             string            `json:"id"`
	Method         string            `json:"method"`
	RandomData     string            `json:"randomData"`
	PolicyRuleHash string            `json:"policyRuleHash,omitempty"`
	Difficulty     int               `json:"difficulty,omitempty"`
	Spent          bool              `json:"spent"`
}
Source: lib/challenge/challenge.go:6-15

Implementation Example: Proof-of-Work

Here’s how the built-in proof-of-work challenge is implemented:
package proofofwork

import (
	"crypto/subtle"
	"fmt"
	"log/slog"
	"net/http"
	"strconv"
	"strings"

	chall "github.com/TecharoHQ/anubis/lib/challenge"
	"github.com/TecharoHQ/anubis/lib/localization"
	"github.com/a-h/templ"
)

func init() {
	chall.Register("fast", &Impl{Algorithm: "fast")
	chall.Register("slow", &Impl{Algorithm: "slow")
}

type Impl struct {
	Algorithm string
}

func (i *Impl) Setup(mux *http.ServeMux) {}

func (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *chall.IssueInput) (templ.Component, error) {
	loc := localization.GetLocalizer(r)
	return page(loc), nil
}

func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *chall.ValidateInput) error {
	rule := in.Rule
	challenge := in.Challenge.RandomData

	nonceStr := r.FormValue("nonce")
	if nonceStr == "" {
		return chall.NewError("validate", "invalid response", fmt.Errorf("%w nonce", chall.ErrMissingField))
	}

	nonce, err := strconv.Atoi(nonceStr)
	if err != nil {
		return chall.NewError("validate", "invalid response", fmt.Errorf("%w: nonce: %w", chall.ErrInvalidFormat, err))
	}

	response := r.FormValue("response")
	if response == "" {
		return chall.NewError("validate", "invalid response", fmt.Errorf("%w response", chall.ErrMissingField))
	}

	calcString := fmt.Sprintf("%s%d", challenge, nonce)
	calculated := internal.SHA256sum(calcString)

	if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 {
		return chall.NewError("validate", "invalid response", fmt.Errorf("%w: wanted response %s but got %s", chall.ErrFailed, calculated, response))
	}

	// compare the leading zeroes
	if !strings.HasPrefix(response, strings.Repeat("0", rule.Challenge.Difficulty)) {
		return chall.NewError("validate", "invalid response", fmt.Errorf("%w: wanted %d leading zeros but got %s", chall.ErrFailed, rule.Challenge.Difficulty, response))
	}

	return nil
}
Source: lib/challenge/proofofwork/proofofwork.go

Implementation Example: Meta Refresh

The meta-refresh challenge validates timing constraints:
func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *challenge.ValidateInput) error {
	wantTime := in.Challenge.IssuedAt.Add(time.Duration(in.Rule.Challenge.Difficulty) * 800 * time.Millisecond)

	if time.Now().Before(wantTime) {
		return challenge.NewError("validate", "insufficient time", 
			fmt.Errorf("%w: wanted user to wait until at least %s", challenge.ErrFailed, wantTime.Format(time.RFC3339)))
	}

	gotChallenge := r.FormValue("challenge")

	if subtle.ConstantTimeCompare([]byte(in.Challenge.RandomData), []byte(gotChallenge)) != 1 {
		return challenge.NewError("validate", "invalid response", 
			fmt.Errorf("%w: wanted response %s but got %s", challenge.ErrFailed, in.Challenge.RandomData, gotChallenge))
	}

	return nil
}
Source: lib/challenge/metarefresh/metarefresh.go:51-64

Registration

Register your challenge implementation in an init() function:
func init() {
	challenge.Register("mycustom", &MyCustomImpl{)
}
The registry is thread-safe and uses sync.RWMutex for concurrent access. Source: lib/challenge/interface.go:15-32

Error Handling

Use the challenge error constructors for consistent error reporting:
var (
	ErrFailed        = errors.New("challenge: user failed challenge")
	ErrMissingField  = errors.New("challenge: missing field")
	ErrInvalidFormat = errors.New("challenge: field has invalid format")
)

func NewError(verb, publicReason string, privateReason error) *Error {
	return &Error{
		Verb:          verb,
		PublicReason:  publicReason,
		PrivateReason: privateReason,
		StatusCode:    http.StatusForbidden,
	}
}
Source: lib/challenge/error.go:9-22

Best Practices

  1. Security-first: Use constant-time comparison for secrets (crypto/subtle.ConstantTimeCompare)
  2. Difficulty scaling: Honor in.Rule.Challenge.Difficulty from the policy configuration
  3. Localization: Use localization.GetLocalizer(r) for internationalized UI
  4. Metrics: Emit Prometheus metrics for observability (see lib/challenge/metrics.go)
  5. Structured logging: Use the provided *slog.Logger for diagnostic output
  6. Templ components: Return templ.Component for HTML rendering consistency

Configuration

Once registered, reference your challenge in policy files:
bots:
  - name: custom-challenge-rule
    action: challenge
    expression:
      - path.startsWith('/protected')
    challenge:
      algorithm: mycustom
      difficulty: 5

Available Challenge Methods

Query registered challenges at runtime:
methods := challenge.Methods()  // Returns []string of all registered challenge types
impl, ok := challenge.Get("fast")  // Get a specific implementation
Source: lib/challenge/interface.go:27-42

Build docs developers (and LLMs) love