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.
Maintainers
Readme
antlion

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.jkttokens,dpop_jkt, PAR and PKCE belong to oidc-provider, Keycloak, and the rest of that shelf. - want one endpoint to accept
BearerandDPoPwhile you migrate. An endpoint that accepts both is exactly as strong asBearer. 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.jkttravels inside the token. - want it to work out your URL from the request.
originis required.Host,X-Forwarded-*andForwardedare 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'svalidateJwtAccessTokendoes 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
-> DPoPVerifiedJwtWhat 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 eitherEdDSAor its fully-specified RFC 9864 nameEd25519, which is what panva's clients send.noneand HMAC can't be expressed.RS256exists only behind an explicit legacy import, the same way Lacewing does it. - Antlion writes the refusal. A 401 with a spec-correct
WWW-Authenticate: DPoPchallenge that lists the accepted algorithms, plusDPoP-NonceandCache-Control: no-storewhen the client needs a fresh nonce. A duplicated or malformedAuthorizationheader is a 400invalid_request, as RFC 6750 asks. The client sees one ofinvalid_request,invalid_token,invalid_dpop_prooforuse_dpop_nonceand 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.
htumust 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
jtifor as long as the proof could still be fresh (maxProofAgeplus six seconds).SingleProcessReplayStoreis for one process and for tests, and its name says so; it also needs amaxEntries, and when it is full it refuses requests rather than forget a proof early. Across a fleet,RedisReplayStoreandPostgresReplayStoreshare 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'siat. When they are on, a nonce is an HMAC over the second it was issued and the origin, valid formaxProofAge, so nothing is stored, every node with the samenonceSecretsaccepts 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
Install it next to Lacewing:
npm install antlion-lacewing lacewingBuild 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", });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 withfromNodeRequestfromantlion-lacewing/node. It readsreq.headersDistinct, becausereq.headershides a secondAuthorizationheader, 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); // KoaPast 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-redisimport { 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 schedulerThe Postgres table is yours to create, and its statement is in the
PostgresReplayStoredocs. Deployment covers which store fits which setup.To turn nonces on, pass
nonce: "required"andnonceSecrets: [secret], wheresecretis 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 carriesnextNonce; send it back asDPoP-NoncewithCache-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.
iatalone 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/extensionexport, so the two can't drift apart. LegacyRS256proofs carry Lacewing'sLEGACY_RS256descriptor, 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
DPoPand to readDPoP-NonceandWWW-Authenticate.DPOP_REQUEST_HEADERSgoes inAccess-Control-Allow-HeadersandDPOP_RESPONSE_HEADERSinAccess-Control-Expose-Headers; the rest of the CORS policy is yours. - Node drops a duplicate
Authorizationheader.req.headerskeeps the first one.fromNodeRequestreadsreq.headersDistinctinstead; if you build the request yourself, do the same, or a second header goes unnoticed. - A throwing
claimValidatorsfunction is a refused token. Lacewing counts it as a failed claim, so it arrives astoken-invalidwith your error at the end of thecausechain. An error from your own code that Lacewing does not catch, such as aKeySourceyou wrote or thenowclock, comes back unchanged. - Two clocks. The profile's
nowdrives proof freshness and nonces. The access token'sexpand age are Lacewing's, onDate.now. - Nodes sharing a replay store need clocks within one second of each other. Each node judges
iaton its own clock, and a replay key livesmaxProofAge + 6seconds. 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
Bearerscheme - Accepting a token with no
cnf.jktunder theDPoPscheme - Verifying the access token against the key in the proof header
- Comparing
htuby prefix, or against a URL rebuilt fromX-Forwarded-Host - Treating
athas optional - Tracking
jtiin 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 publishruns 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.
