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

antlion-lacewing

v1.0.1

Published

DPoP (RFC 9449) proof verification for Lacewing: one call checks the access token, the proof and the key binding together, or rejects the request.

Readme

antlion

antlion logo

Antlion makes DPoP mistakes impossible by construction. It is for resource servers that already verify access tokens with Lacewing and now need those tokens bound to a key: one call checks the access token, the proof and the key binding together, or rejects the request.

It implements the resource-server side of RFC 9449 (DPoP), with the algorithm set from the FAPI 2.0 Security Profile. A token stolen off the wire, out of a log, or from a proxy is useless to anyone who does not also hold the client's private key. It does not create proofs, run an authorization server, or read client certificates.

Lacewing is a peer dependency, and the access token is still verified by a Lacewing profile. jose is the one direct dependency, used only for the proof. Nothing happens at import time.

Antlion is not for you if you

  • want to create DPoP proofs. That is the client's side. oauth4webapi and openid-client already do it well, including the retry on a nonce challenge.
  • run an authorization server. Issuing cnf.jkt tokens, dpop_jkt, PAR and PKCE belong to oidc-provider, Keycloak, and the rest of that shelf.
  • want one endpoint to accept Bearer and DPoP while you migrate. An endpoint that accepts both is exactly as strong as Bearer. Route the two kinds of traffic to two profiles, on purpose, and delete the old route when you are done.
  • bind tokens with mTLS. RFC 8705 works at the TLS layer and needs the client certificate to reach your service. Nothing here reads a certificate.
  • use opaque tokens and introspection. v1 is JWT access tokens (RFC 9068) only, where cnf.jkt travels inside the token.
  • want it to work out your URL from the request. origin is required. Host, X-Forwarded-* and Forwarded are never read, because they are whatever the client or the nearest proxy says they are.
  • want replay protection without state. A proof is single-use, so something has to remember which ones it has seen. The replay store is required by the type, and there is no safe DPoP verifier that is stateless.
  • are not using Lacewing. Antlion wraps a Lacewing profile and adds nothing to what that profile checks. Without Lacewing, oauth4webapi's validateJwtAccessToken does the binding correctly; With panva's tools lists what to add around it.

How it works

The access token, the proof and the binding between them are one check. There is no function that verifies a proof on its own, because a valid proof only shows that someone holds some key. The security is in tying that key to the token and the proof to this request, so a result only exists once all of it has passed.

In outline, verifyDPoPRequest(request, profile) does this, in this order, and stops at the first refusal:

one  Authorization: DPoP <token>   (Bearer refused, duplicates refused)
one  DPoP: <proof>                 (duplicates refused)

proof:     size, shape, typ is dpop+jwt, alg on the allowlist and matching the key
request:   htm == method, htu == configured origin + normalised path
proof:     signature, against the public key in its own header
token:     your Lacewing profile, unchanged
binding:   thumbprint(proof key) == token.cnf.jkt
           proof.ath == sha256(token)
freshness: iat inside a capped window, server nonce if you turned nonces on
replay:    first time this proof has been seen, or refuse

-> DPoPVerifiedJwt

What follows from that:

  • There is one way to verify. No proof-only verifier is exported, so "checked the proof, forgot the binding" is not something you can write.
  • Cheap checks run before expensive ones, and the store runs last. Nothing unauthenticated reaches the replay store, so garbage can't be used to fill it or probe it.
  • The key in a proof header is trusted for the proof and nowhere else. Verifying against an embedded key happens in exactly one internal module, and the access token never goes near it.
  • The algorithms are the FAPI 2.0 set: PS256, ES256, and Ed25519 under either EdDSA or its fully-specified RFC 9864 name Ed25519, which is what panva's clients send. none and HMAC can't be expressed. RS256 exists only behind an explicit legacy import, the same way Lacewing does it.
  • Antlion writes the refusal. A 401 with a spec-correct WWW-Authenticate: DPoP challenge that lists the accepted algorithms, plus DPoP-Nonce and Cache-Control: no-store when the client needs a fresh nonce. A duplicated or malformed Authorization header is a 400 invalid_request, as RFC 6750 asks. The client sees one of invalid_request, invalid_token, invalid_dpop_proof or use_dpop_nonce and nothing more; the precise reason comes back to you as a typed code for your own logs.

What you decide

A DPoP profile wraps the Lacewing profile you already have. Four things have no default, because only you know the answer:

defineDPoPProfile:
  token:   your Lacewing access-token profile
  origin:  "https://api.example.com"
  replay:  a store with one atomic addIfAbsent(key, ttlSeconds)
  nonce:   "required" (with nonceSecrets) | "off"
  • The origin is the one your clients sign against, written the way a URL parser prints it: lowercase host, no default port, no trailing slash. htu must equal it plus the request's path, after RFC 3986 normalisation, with the query and fragment removed. Anything else is refused when the profile is built, with a message saying what you meant.
  • The replay store holds each proof's key thumbprint and a hash of its jti for as long as the proof could still be fresh (maxProofAge plus six seconds). SingleProcessReplayStore is for one process and for tests, and its name says so; it also needs a maxEntries, and when it is full it refuses requests rather than forget a proof early. Across a fleet, RedisReplayStore and PostgresReplayStore share one store between every process, through the Redis or Postgres client you already have. If the store errors, the request is refused.
  • Nonces are a trade. "required" costs an extra round trip whenever a client's nonce is stale, and stops proofs being generated ahead of time with a lying clock. "off" trusts the client's iat. When they are on, a nonce is an HMAC over the second it was issued and the origin, valid for maxProofAge, so nothing is stored, every node with the same nonceSecrets accepts the same nonces, and rotating is putting a new secret first in the list.

One more setting has a default, and it is the strict one. maxProofAge, how old a proof's iat may be, is 60 seconds and can be raised to 300 at most, which is what oauth4webapi allows. A client clock running up to 5 seconds ahead is accepted, and that is not configurable.

Quick start

  1. Install it next to Lacewing:

    npm install antlion-lacewing lacewing
  2. Build the profile once, next to the Lacewing profile it wraps:

    import { accessTokenProfile } from "lacewing";
    import { defineDPoPProfile, SingleProcessReplayStore } from "antlion-lacewing";
    
    const dpop = defineDPoPProfile({
    	token: accessTokenProfile({
    		issuer: "https://auth.example.com",
    		audience: "https://api.example.com",
    		algorithms: ["ES256"],
    		keys: { jwksUri: "https://auth.example.com/jwks" },
    	}),
    	origin: "https://api.example.com",
    	replay: new SingleProcessReplayStore({ maxEntries: 100_000 }),
    	nonce: "off",
    });
  3. Verify every request, and send the refusal Antlion wrote when it says no:

    import { AntlionError, verifyDPoPRequest } from "antlion-lacewing";
    
    async function handle(request: Request): Promise<Response> {
    	try {
    		const { token } = await verifyDPoPRequest(request, dpop);
    		return Response.json({ hello: token.payload.sub });
    	} catch (error) {
    		if (error instanceof AntlionError && error.refusal !== undefined) {
    			// error.code says which check failed. Log it; don't send it.
    			return new Response(null, error.refusal);
    		}
    		throw error;
    	}
    }

    On Node's own http, Express, Fastify or Koa, build the request with fromNodeRequest from antlion-lacewing/node. It reads req.headersDistinct, because req.headers hides a second Authorization header, and the path the client signed, which a mounted router rewrites. Frameworks has each one:

    import { fromNodeRequest } from "antlion-lacewing/node";
    
    await verifyDPoPRequest(fromNodeRequest(req), dpop);                     // node:http, Express
    await verifyDPoPRequest(fromNodeRequest(request.raw), dpop);             // Fastify
    await verifyDPoPRequest(fromNodeRequest(ctx.req, ctx.originalUrl), dpop); // Koa
  4. Past one process, give every process the same store, built on the client you already have. Antlion imports neither client, and adds no dependency:

    import { RedisReplayStore } from "antlion-lacewing/redis";
    
    const replay = new RedisReplayStore({ client: redis, prefix: "dpop:" }); // ioredis or node-redis
    import { PostgresReplayStore } from "antlion-lacewing/postgres";
    
    const replay = new PostgresReplayStore({ client: pool, table: "dpop_replay" }); // a pg Pool
    setInterval(() => replay.deleteExpired().catch(console.error), 60_000); // or your own scheduler

    The Postgres table is yours to create, and its statement is in the PostgresReplayStore docs. Deployment covers which store fits which setup.

  5. To turn nonces on, pass nonce: "required" and nonceSecrets: [secret], where secret is at least 32 random bytes (crypto.getRandomValues(new Uint8Array(32)), stored where your other secrets are) and is the same on every node. A verified request then carries nextNonce; send it back as DPoP-Nonce with Cache-Control: no-store, and clients never wait for a 401 to learn it.

What DPoP does, and what it does not

People expect more from DPoP than it gives, and then expect it from Antlion.

  • It stops replay, not theft. A leaked token is useless without the key. Malware or an XSS running inside the client can still use the key to sign fresh proofs, and nothing on the server can tell. Antlion makes leaked tokens worthless; it can't protect a compromised client.
  • It hides nothing. The token is as readable as it was before. If the payload is a secret, that is Lacewing's JWE support.
  • It isn't stateless. Replay protection needs a store, and nonces need either shared state or a shared secret.
  • iat alone is not freshness. It is the client's clock, so a proof can be signed today for use tomorrow. Server nonces close that.
  • It is not interchangeable with mTLS. FAPI accepts both, but they fit different infrastructure. DPoP lives in the application layer and survives TLS being terminated at a proxy; mTLS needs the certificate to reach you.

Documentation

| Guide | What it covers | | --- | --- | | Boundaries | What Antlion owns, what it will never do, and where that work goes | | With panva's tools | oidc-provider and openid-client around Antlion, what has to match, and when to use oauth4webapi instead | | Frameworks | A WHATWG Request, Node's http, Express, Fastify and Koa: building the request and sending the refusal | | Deployment | Choosing a replay store, sizing it, nonces across nodes, rolling deploys and clock skew | | Threat model | What is protected, against whom, what is assumed, and what is out of scope | | Verification | Mutation score, fuzzing, the attack corpus, live interop, and the FAPI suite's status |

Where it sits

| Package | Question it answers | | --- | --- | | Lacewing | Is this token signed by who it claims, unexpired, and meant for me? | | Antlion | Is the one presenting it the one it was issued to, and is this request the one they signed? |

A whole FAPI 2.0 setup with DPoP, in one line:

Lacewing + Antlion  +  oidc-provider         +  openid-client
(your API)             (authorization server)   (the client)

Antlion reads the headers, verifies the proof, hands the token to your Lacewing profile, then checks that the two belong together. oidc-provider, or Keycloak, Curity or whatever else you run, puts cnf.jkt in the token. openid-client, built on oauth4webapi, holds the key and signs a proof for every request. Antlion is only the first part. Past one node, add a shared replay store (Redis or Postgres). With panva's tools covers what the three have to agree on.

Known quirks

  • It needs Lacewing 2.0.0 or later. Antlion shares Lacewing's algorithm table, header reading, duration parsing and strict JSON reading through the lacewing/extension export, so the two can't drift apart. Legacy RS256 proofs carry Lacewing's LEGACY_RS256 descriptor, which works only on the profile it is passed to; 2.0 has no process-wide registry to switch on.
  • JWT access tokens only. Opaque tokens and introspection are out of v1, and may stay out.
  • Browsers need CORS. A browser client has to be allowed to send DPoP and to read DPoP-Nonce and WWW-Authenticate. DPOP_REQUEST_HEADERS goes in Access-Control-Allow-Headers and DPOP_RESPONSE_HEADERS in Access-Control-Expose-Headers; the rest of the CORS policy is yours.
  • Node drops a duplicate Authorization header. req.headers keeps the first one. fromNodeRequest reads req.headersDistinct instead; if you build the request yourself, do the same, or a second header goes unnoticed.
  • A throwing claimValidators function is a refused token. Lacewing counts it as a failed claim, so it arrives as token-invalid with your error at the end of the cause chain. An error from your own code that Lacewing does not catch, such as a KeySource you wrote or the now clock, comes back unchanged.
  • Two clocks. The profile's now drives proof freshness and nonces. The access token's exp and age are Lacewing's, on Date.now.
  • Nodes sharing a replay store need clocks within one second of each other. Each node judges iat on its own clock, and a replay key lives maxProofAge + 6 seconds. From one second of difference on, a proof accepted early by the faster node can be accepted again late by the slower one. Deployment has the arithmetic and the test.
  • The numbers below are from one laptop. Re-run them with npm run bench, or on a GitHub runner with the Bench workflow.

Performance

npm run bench times verifyDPoPRequest one request at a time, on one core, with SingleProcessReplayStore. Proofs are signed beforehand, so what is timed is verifying the proof, the ES256 access token through Lacewing, the binding, and the store write. On Node 26.10, Linux, an Intel Core 7 350:

| Request | Per second | p50 | p99 | | --- | ---: | ---: | ---: | | accepted, ES256 proof | 2,595 | 0.337 ms | 0.976 ms | | accepted, Ed25519 proof | 3,097 | 0.299 ms | 0.742 ms | | accepted, PS256 proof | 2,966 | 0.304 ms | 0.735 ms | | accepted, ES256 proof, nonces on | 2,406 | 0.373 ms | 0.898 ms | | refused: Bearer scheme | 42,431 | 0.019 ms | 0.036 ms | | refused: htu mismatch, before any signature | 46,217 | 0.020 ms | 0.035 ms | | refused: bad proof signature | 4,019 | 0.210 ms | 0.601 ms |

Two runs on this machine differed by under 6% on accepted requests and by up to 40% on the cheapest refusals, which finish in tens of microseconds and feel every scheduler hiccup. A request that fails before any signature costs about a sixteenth of one that verifies, which is the point of the fixed order. A shared store over the network adds its round trip to every accepted request, and nothing here measures that.

Why Antlion exists

Lacewing's README says proof-of-possession is beyond its bearer-token scope, and it meant it. Then a Lacewing user needed FAPI 2.0, which requires tokens bound to the client, and DPoP is the binding that works behind a TLS-terminating proxy. So the insect grew a sibling.

The JavaScript resource-server side is thin. panva's oauth4webapi does validate DPoP-bound access tokens, and it is excellent, but it is a general OAuth client with its own settings. Putting it under Lacewing would mean two policy layers that can disagree about what "valid" means, and the disagreement would be where the bug lives.

DPoP is a short spec with a lot of room to get it quietly wrong, and every one of these passes a test suite that only ever sends valid requests:

  • Verifying the proof and the token, and never comparing cnf.jkt
  • Accepting a DPoP-bound token under the Bearer scheme
  • Accepting a token with no cnf.jkt under the DPoP scheme
  • Verifying the access token against the key in the proof header
  • Comparing htu by prefix, or against a URL rebuilt from X-Forwarded-Host
  • Treating ath as optional
  • Tracking jti in memory on each of twenty nodes, or failing open when the store is down
  • Letting the header pick the algorithm

Each of those is either impossible to express in Antlion or enforced on every request.

Scripts

| Script | What it does | | --- | --- | | npm run build | Compile src/ and index.ts to dist/ with type declarations. | | npm run typecheck | Type-check the package and the tests without emitting. | | npm run lint | Run ESLint. | | npm run lint:fix | Run ESLint and fix what it can. | | npm test | Run the test suite with the Node test runner via tsx. | | npm run test:dist | Build, then test the built package through its exports map. | | npm run test:services | Test the Redis and Postgres stores, several processes sharing them, and a live Nimbus client. Needs ANTLION_TEST_REDIS_URL, ANTLION_TEST_POSTGRES_URL, a JDK and the Nimbus jars; the CI services job sets all of them up. | | npm run mutation | Mutation-test the verifier with Stryker and report what the tests miss; writes reports/mutation/. | | npm run bench | Time verification and early refusals, and print a Markdown table with the machine it ran on. | | npm run compliance | Run the suite and fail if any requirement in tests/compliance/requirements.json has no passing test; writes compliance-report.md. | | npm run docs | Generate the API reference into docs/ with TypeDoc. | | npm run changelog | Regenerate CHANGELOG.md from the commit history. |

Publishing and deployment are handled manually (custom npm settings), so no release/publish workflow is included here. npm publish runs lint, typecheck, the tests, the compliance gate and the built-artifact tests first (prepublishOnly), and stops if any of them fails.

License

MIT

Attribution

Antlion depends on and includes code adapted from the jose library, created by Filip Skokan (panva). The same credit is in NOTICE.md, which ships with the package. We are grateful for their work!

Lore

Antlions and lacewings are both Neuroptera, and a grown antlion is easy to mistake for a lacewing. It isn't one, and it doesn't replace one.

The larva is the famous part. Some species dig a cone in loose sand at exactly the steepest angle the sand will hold, and sit at the bottom. An ant that steps over the rim finds that every step it takes brings the slope down with it. The antlion doesn't chase anything; the shape of the pit does the work. That is the whole design brief.