> ## Documentation Index
> Fetch the complete documentation index at: https://ribaunt-e66481b6-mintlify-4e7c3afc.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# createChallenge

> createChallenge() issues signed JWT challenge tokens for Ribaunt proof-of-work CAPTCHA verification on the server.

`createChallenge()` is imported from `ribaunt` and called server-side to generate one or more proof-of-work challenge tokens. Each token is a signed JWT that the browser solver decodes and works against.

## Import

```ts theme={null}
import { createChallenge, selectWorkload, calibrateNode, calibrateClient } from 'ribaunt';
```

## Signature

```ts theme={null}
function createChallenge(
  difficulty?: number,
  amount?: number,
  ttlSeconds?: number
): Promise<ChallengeToken[]>

function createChallenge(options: ChallengeOptions): Promise<ChallengeToken[]>
```

<Note>
  `createChallenge()` is asynchronous and returns a `Promise<ChallengeToken[]>`. Always `await` it. The async signature exists so that optional hooks like `rateLimiter` can be awaited before a challenge is issued.
</Note>

<Note>
  `difficulty` accepts either a positive integer or the string `"auto"`. In `"auto"` mode, Ribaunt picks a `difficulty` and `amount` at runtime using `selectWorkload()` based on an optional client `calibration`, a server-side `riskScore`, `targetDurationMs`, and the `min`/`max` bounds you configure. Calibration is treated as untrusted — a fast benchmark can only *raise* work up to your maximums, never lower the server-owned baseline.
</Note>

## Parameters

You can call `createChallenge()` in either of two styles:

* positional arguments: `createChallenge(difficulty, amount, ttlSeconds)`
* an options object: `createChallenge({ difficulty, amount, ttlSeconds, context, workload })`

<ParamField path="difficulty" default="5" type="number | &#x22;auto&#x22;">
  Number of leading zero hex digits required in the hash. Each increment roughly doubles solve time. SHA-256 accepts `1`–`64` (values above 6 may cause browsers to hang); Argon2id accepts `1`–`8`. Pass `"auto"` to have Ribaunt select difficulty and amount adaptively — see [Adaptive workload](#adaptive-workload) below.
</ParamField>

<ParamField path="algorithm" default="sha256" type="'sha256' | 'argon2id'">
  Proof-of-work algorithm. The default `sha256` hashes in microseconds and verifies cheaply. Opt in to `argon2id` for a memory-hard algorithm that raises the cost of GPU and ASIC solver farms. See [Argon2id opt-in](#argon2id-opt-in) below.
</ParamField>

<ParamField path="argonProfile" default="mobile" type="'mobile' | 'standard'">
  Memory-hardness profile, only valid with `algorithm: 'argon2id'`. The profile abstracts the raw Argon2id parameters (`m`, `t`, `p`) so you never pass memory sizes yourself. Both profiles currently resolve to the same conservative tuning; pick the tier that matches your audience now, and future retuning will not break in-flight tokens because each token embeds its own parameters. Passing `argonProfile` with `algorithm: 'sha256'` throws.
</ParamField>

<ParamField path="amount" default="4" type="number">
  Number of challenge tokens to generate. More challenges increase total proof-of-work but also increase network bandwidth.
</ParamField>

<ParamField path="ttlSeconds" default="30" type="number">
  Challenge token lifetime in seconds. Tokens submitted after expiry are rejected by `verifySolution`.
</ParamField>

<ParamField path="context" type="string">
  Optional scope string that is bound into the challenge token. Supply the same value to `verifySolution({ context })` to require that the same context is used when verifying.
</ParamField>

<ParamField path="workload" type="Pick<Workload, 'difficulty' | 'amount'>">
  Optional shorthand for setting the challenge difficulty and amount together. Use this when you want to keep the challenge configuration in a single object.
</ParamField>

### Auto-hardness options

These fields are only used when `difficulty` is `"auto"`.

<ParamField path="targetDurationMs" default="750" type="number">
  Desired browser solve time in milliseconds. The selector aims for this duration when it has calibration data.
</ParamField>

<ParamField path="riskScore" default="50" type="number">
  Server-side risk appetite from 0–100. Higher scores bias the selector toward more work within your configured bounds, independent of the client calibration.
</ParamField>

<ParamField path="calibration" type="ClientCalibration">
  Untrusted client benchmark, typically forwarded from the widget when `challenge-method="POST"` and `calibrate="true"` are set. Used as a raise-only signal: fast calibration can increase work up to your maximum bounds, a slow or fake one cannot reduce it below the server baseline.
</ParamField>

<ParamField path="minDifficulty" default="3" type="number">
  Lower bound for `difficulty` when using `"auto"`. Defaults to `1` when `algorithm` is `'argon2id'`.
</ParamField>

<ParamField path="maxDifficulty" default="6" type="number">
  Upper bound for `difficulty` when using `"auto"`. Defaults to `2` when `algorithm` is `'argon2id'`.
</ParamField>

<ParamField path="minAmount" default="1" type="number">
  Lower bound for `amount` when using `"auto"`.
</ParamField>

<ParamField path="maxAmount" default="8" type="number">
  Upper bound for `amount` when using `"auto"`.
</ParamField>

### Hooks

<ParamField path="rateLimiter" type="RateLimiter">
  Optional bring-your-own rate limiter. `createChallenge()` calls `rateLimiter.check(context)` before issuing tokens. If the limiter resolves `false`, Ribaunt throws a `RateLimitedError` (`code: 'rate-limited'`) and does not issue any tokens. Use this to reject abusive callers by IP, session, or user before spending JWT signing work.
</ParamField>

<ParamField path="onEvent" type="(event: RibauntEvent) => void">
  Optional telemetry hook. `createChallenge()` calls it with `{ type: 'challenge-issued', difficulty, amount, algorithm }` after tokens are issued. Errors thrown by the callback are caught and ignored so telemetry cannot break challenge issuance. See the [Types reference](/api/types#ribauntevent) for the full event union.
</ParamField>

## Return value

Returns `Promise<ChallengeToken[]>` — an array of signed JWT strings. Send this array to the browser as `{ challenges: tokens }`.

## Examples

```ts theme={null}
import { createChallenge, selectWorkload } from 'ribaunt';

const fast = await createChallenge({
  difficulty: 4,
  amount: 4,
  ttlSeconds: 30,
});

const moderate = await createChallenge({
  difficulty: 5,
  amount: 4,
  ttlSeconds: 120,
  context: 'signup',
});

// Adaptive workload from a risk score and target duration
const workload = selectWorkload({
  riskScore: 75,
  targetDurationMs: 750,
});

const adaptive = await createChallenge({
  workload,
  ttlSeconds: 120,
});
```

### Rate-limited challenge issuance

```ts theme={null}
import { createChallenge, RateLimitedError } from 'ribaunt';

try {
  const challenges = await createChallenge({
    difficulty: 5,
    amount: 4,
    ttlSeconds: 60,
    rateLimiter: {
      check: async () => allowRequest(req.ip), // your bucket / Redis check
    },
  });
  res.json({ challenges });
} catch (error) {
  if (error instanceof RateLimitedError) {
    return res.status(429).json({ error: error.message });
  }
  throw error;
}
```

<Warning>
  Key the limiter on the client IP by closing over the request, as shown above. Do not pass `context: req.ip` for rate limiting. `context` cryptographically binds every token to that value, so `verifySolution()` must receive the exact same `context` or verification fails with `context-mismatch`. Reserve `context` for binding a challenge to a specific action, such as one signup attempt.
</Warning>

### Telemetry with `onEvent`

```ts theme={null}
const challenges = await createChallenge({
  difficulty: 5,
  amount: 4,
  ttlSeconds: 60,
  onEvent: (event) => {
    if (event.type === 'challenge-issued') {
      metrics.increment('ribaunt.challenge_issued', {
        difficulty: event.difficulty,
        amount: event.amount,
      });
    }
  },
});
```

## Adaptive workload

Two paths are supported for adaptive difficulty:

1. Pass `difficulty: "auto"` directly to `createChallenge()` and let it call the selector internally.
2. Call `selectWorkload()` yourself and pass the result via `workload`.

Both use the same engine, so the results match for equivalent inputs.

```ts theme={null}
// Option 1: let createChallenge pick the workload
const challenges = await createChallenge({
  difficulty: 'auto',
  calibration: body.calibration,
  targetDurationMs: 750,
  minDifficulty: 3,
  maxDifficulty: 6,
  minAmount: 1,
  maxAmount: 8,
  ttlSeconds: 60,
});

// Option 2: pre-compute the workload
const workload = selectWorkload({
  riskScore: 70,
  targetDurationMs: 800,
  calibration: {
    iterations: 250_000,
    durationMs: 200,
  },
});

const challenges = await createChallenge({ workload, ttlSeconds: 60 });
```

`selectWorkload()` respects the configured bounds and returns a `Workload` object with `difficulty`, `amount`, `estimatedAttempts`, and `algorithm`. It accepts the same `algorithm` and `argonProfile` options as `createChallenge()`; for `argon2id` the result also includes the resolved `argon` parameters.

<Note>
  To derive `riskScore` from application signals such as account age or request velocity instead of hardcoding it, use [`assess()`](/api/assess). When it recommends a challenge, it returns a ready-made `Workload` you can pass here.
</Note>

### Calibration helpers

Ribaunt exposes calibration helpers for both environments so you can benchmark the runtime that will actually solve the challenge:

```ts theme={null}
import { calibrateNode, calibrateClient } from 'ribaunt';       // Node.js, SHA-256
import { calibrateArgonNode, calibrateArgonClient } from 'ribaunt';  // Node.js, Argon2id

import { calibrateBrowser, calibrateClient } from 'ribaunt/widget';       // browser, SHA-256
import { calibrateArgonBrowser, calibrateArgonClient } from 'ribaunt/widget'; // browser, Argon2id
```

`calibrateClient` and `calibrateArgonClient` are cross-environment aliases — bundlers resolve the correct implementation via the package export map.

<Warning>
  Use the calibrator that matches your `algorithm`. A SHA-256 calibration passed to an `argon2id` workload (or vice versa) will over- or under-estimate the device and skew the selected difficulty.
</Warning>

<Warning>
  Any `calibration` value coming from a browser request is untrusted. Ribaunt only uses it to raise work above the server-owned baseline, but you should still validate its shape before passing it through.
</Warning>

## Argon2id opt-in

By default, Ribaunt uses SHA-256, which hashes in microseconds and keeps server-side verification cheap. Opt in to `argon2id` when you want a memory-hard proof of work: each hash allocates a fixed amount of memory, which makes large-scale solving on GPUs and ASICs far more expensive relative to a real user's browser.

```ts theme={null}
import { createChallenge, calibrateArgonNode } from 'ribaunt';

const challenges = await createChallenge({
  algorithm: 'argon2id',
  argonProfile: 'mobile',
  difficulty: 'auto',
  calibration: await calibrateArgonNode(), // must match the algorithm
  ttlSeconds: 120,
});
```

Key differences from SHA-256:

* **Difficulty scale.** Each Argon2id hash takes milliseconds instead of microseconds, so difficulty caps at `8` and `"auto"` bounds default to `1`–`2` (versus `64` and `3`–`6` for SHA-256).
* **Profiles instead of raw parameters.** `argonProfile` resolves the Argon2id memory, iteration, and parallelism parameters for you. The library enforces a hard upper bound (`HARD_MAX`, exported from `ribaunt`) on those parameters, and tokens carrying values above it are rejected as `invalid-token`.
* **Tokens are self-describing.** Each challenge token carries its algorithm, its Argon2id parameters, and a construction version (`v: 1`), signed into the JWT. `verifySolution()` detects the algorithm per token, so your verify endpoint needs no changes, and future profile retuning cannot break tokens that are already in flight.
* **Browser support is automatic.** The widget and its solver worker detect the algorithm per token and load the Argon2id solver on demand. The [`solver-backend` event](/widget/events#solver-backend) reports `argon2id` so you can confirm it in telemetry.
* **Testing.** The synchronous [`solveChallenge()`](/api/solve-challenge) helper supports SHA-256 only. Use `solveChallengeAsync()` in tests that solve `argon2id` tokens.

The adaptive engine works the same for both algorithms: `riskScore` and `calibration` remain raise-only signals within your configured bounds. [`assess()`](/api/assess) accepts `algorithm` and `argonProfile` in its `workload` options when you want the risk engine to produce Argon2id workloads.

<Warning>
  Argon2id narrows the attacker's GPU and ASIC advantage but does not eliminate it. A well-funded adversary can still solve any proof-of-work challenge, just at a higher memory cost. Treat it as one abuse-cost layer alongside rate limiting and risk signals, not as human verification or the sole gate for sensitive actions.
</Warning>

## Validation

`createChallenge()` validates its inputs at runtime and throws if anything is invalid:

* **`difficulty`** — must be a finite number and at least `1`. Fractional values are rounded down with `Math.floor()`. The maximum is `64` for `sha256` and `8` for `argon2id`.
* **`algorithm`** — must be `'sha256'` or `'argon2id'` when provided.
* **`argonProfile`** — must be `'mobile'` or `'standard'`, and is only accepted when `algorithm` is `'argon2id'`.
* **`amount`** — must be a finite number and at least `1`. Fractional values are rounded down with `Math.floor()`.
* **`ttlSeconds`** — must be a finite number and at least `1`. Fractional values are rounded down with `Math.floor()`.
* **`workload`** — if you provide it, the selected values must still fit the configured bounds.

<Warning>
  Never let user-controlled request parameters flow directly into `createChallenge()` without validation.
</Warning>

<Note>
  Requires `RIBAUNT_SECRET` to be set as an environment variable. `createChallenge()` throws if the secret is missing or shorter than 32 UTF-8 bytes.
</Note>
