npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@weggle/pow-gate

v1.0.0

Published

Purpose-bound, single-use proof-of-work challenges with a server-measured dwell time. Stateless tokens, no default secret.

Readme

pow-gate

Purpose-bound, single-use proof-of-work challenges with a server-measured dwell time.

No dependencies. Node 18+.

npm install @weggle/pow-gate

What it is for

You have a form that costs you something when a bot submits it — a registration, a password reset, a contact form, an invite request. You do not want a third-party CAPTCHA on it.

Proof of work makes each submission cost the client a fixed amount of CPU. It does not stop a determined attacker; nothing does. It changes the economics: a bot doing 100 registrations an hour has to spend real compute per attempt instead of firing requests for free.

This is small on purpose. It issues a token, checks a solution, and refuses to let the same solution be used twice.

Quick start

const { createPowGate } = require('@weggle/pow-gate');

const gate = createPowGate({
  secret: process.env.POW_SECRET,   // required, 16+ chars
  difficulty: 5,                    // leading hex zeros, 1-8
  minAgeMs: 1500,                   // reject forms submitted faster than this
});

// When you render the form:
app.get('/register', (req, res) => {
  const challenge = gate.issue('register');
  res.render('register', { challenge });   // put challenge.token in a hidden field
});

// When it comes back:
app.post('/register', (req, res) => {
  const result = gate.verifyAndConsume(req.body.powToken, req.body.powNonce, {
    purpose: 'register',
  });
  if (!result.ok) return res.status(400).send(`Rejected: ${result.reason}`);

  // ...create the account
});

Client side, in a worker so the page stays responsive:

async function solve(token, difficulty) {
  const target = '0'.repeat(difficulty);
  for (let nonce = 0; ; nonce++) {
    const bytes = new TextEncoder().encode(token + nonce);
    const hash = await crypto.subtle.digest('SHA-256', bytes);
    const hex = [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, '0')).join('');
    if (hex.startsWith(target)) return String(nonce);
  }
}

Difficulty 5 is roughly 0.3–0.8s on current hardware. Measure on the slowest device you care about before raising it — the cost lands on real users too.

The four properties

Most homegrown PoW gates get the hashing right and miss at least two of these.

Purpose binding. A token records the flow it was minted for, inside the signature and inside the hashed work. A challenge harvested from your open registration page cannot be spent on password reset. Add a new guarded flow later and it does not inherit a supply of pre-solved challenges.

const { token } = gate.issue('register');
gate.verify(token, nonce, { purpose: 'password-reset' });  // → { ok: false, reason: 'wrong purpose' }

Single use. The token is stateless — an HMAC, no server storage — which is what makes it usable from a handler with no database. That is also the catch: a signature that verifies once verifies for the whole expiry window. Without a record of what has been spent, one solved challenge pays for unlimited submissions until it expires. consume keeps that record.

gate.verifyAndConsume(token, nonce);   // → { ok: true }
gate.verifyAndConsume(token, nonce);   // → { ok: false, reason: 'already used' }

Server-measured dwell. The usual way to check "was this form open long enough" is a hidden field holding the page's load time. The client picks that value, so it is a claim the submitter makes about itself — subtract three seconds and the check passes. Here the issue time is inside the signed token, so the only way to make a form look old is for it actually to be old.

No default secret. createPowGate throws without one. A fallback secret in a library is a signing key every install shares, and anyone who has read the source can forge tokens for all of them.

API

createPowGate(options)

| Option | Default | Meaning | |---|---|---| | secret | required | HMAC key, 16+ characters | | difficulty | 5 | leading hex zeros required (1–8) | | expiryMs | 600000 | token lifetime (10 minutes) | | minAgeMs | 0 | reject a token solved faster than this | | store | in-process | spent-token store (see below) | | rateLimit | null | { max, windowMs } for the optional per-key attempt cap |

Methods

| Method | Returns | |---|---| | issue(purpose) | { token, difficulty, purpose, expiryMs } | | verify(token, nonce, opts) | { ok, reason?, issuedAt?, age?, purpose?, difficulty? } — does not spend the token | | consume(token) | boolean — false if already spent (a promise, if the store is async) | | verifyAndConsume(token, nonce, opts) | verify then consume; what a handler usually wants (a promise, if the store is async) | | age(token) | ms since issue, or null | | solve(token) | a valid nonce — for tests and server-side clients | | takeAttempt(key) | boolean — the optional attempt cap |

opts accepts purpose and minAgeMs (overriding the gate default).

Rejection reasons: missing fields, malformed token, invalid signature, wrong purpose, expired, too fast, invalid difficulty, proof of work failed, already used.

Call verify before consume — never record a token that has not verified, or anyone can burn a challenge they did not solve and fill the store with arbitrary strings.

Running more than one instance

The default store is an in-process Map. It is bounded (a token is only recorded once its proof verified, and entries are pruned at expiry) but it is per process. Behind a load balancer, a solved challenge can be spent once per instance.

Pass a shared store — anything with add(token, expiresAt) → boolean and has(token) → boolean, where add returns false if the token was already present. Either method may return a promise:

const gate = createPowGate({
  secret: process.env.POW_SECRET,
  store: {
    async add(token, expiresAt) {
      const ttl = Math.ceil((expiresAt - Date.now()) / 1000);
      return (await redis.set(`pow:${token}`, '1', 'NX', 'EX', ttl)) === 'OK';
    },
    async has(token) { return (await redis.exists(`pow:${token}`)) === 1; },
  },
});

Redis SET NX is exactly the right primitive: it is atomic, so two instances racing on the same token cannot both win.

Await the result when your store is asynchronous. verifyAndConsume and consume answer in whatever form the store does — a plain value for the built-in store, a promise for one backed by a network call. Awaiting is safe in both cases, so a handler that always awaits works with either store:

app.post('/register', async (req, res) => {
  const result = await gate.verifyAndConsume(req.body.powToken, req.body.powNonce, {
    purpose: 'register',
  });
  if (!result.ok) return res.status(400).send(`Rejected: ${result.reason}`);
  // ...create the account
});

Forgetting the await here is the one mistake worth guarding against: a promise is always truthy, so the replay check would pass every time and the single-use property would be lost without any error.

The same applies to rateLimit, which is also per-process. If you need a shared attempt cap, use your existing rate limiter rather than this one.

What this is not

  • Not a CAPTCHA. It costs CPU, not human attention. Someone willing to spend compute gets through.
  • Not a rate limiter. The built-in rateLimit is a convenience for the per-IP case; it does not replace a proper limiter in front of your app.
  • Not abuse detection. It says a client did some work. It says nothing about who they are or what they intend.

Use it as one layer. It pairs well with a request rate limit and ordinary server-side validation.

Tests

npm test

34 tests, written against the properties rather than the happy path: tokens signed with another key, every signed field tampered with in turn, solutions moved between flows, replays, expiry, future timestamps, malformed input, and the store seam — synchronous and asynchronous alike.

License

MIT