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

email-verification-api

v0.2.0

Published

Verify Email Verification Protocol EVT+KB tokens

Readme

📧 ✅ Email Verification API for Node.js

The Email Verification API is a proposed standard to help users verify their email addresses without having to send one time passwords (OTPs) or magic links.

Instead, the browser will use a user's logged in session to their inbox to create a cryptographically signed token that a server can use to verify the email address.

This project provides a function for web developers to verify the Email Verification Token (EVT).

Read more about the Email Verification API and how to use it.

[!Warning] This library is targeting a draft specification that is under development. It may be out of date at times, but I will be updating it to match what is specified and supported by browsers and mailbox providers.\

There is a demo app in the ./example directory and you can interact with it in this hosted demo.

How to use

Installation

First install the package:

npm install email-verification-api

Example app

There is an example Next.js application in the example directory. See the README for how to run the example.

Get an Email Verification Token

Full details on how to implement a verifier site are available in this Chrome Developer article, but the process looks like this:

  • On the server, generate a random nonce and bind it to the user session
  • Render an HTML page with a form, an input field for the email address, and a hidden input field for the email verification token with the nonce as an attribute on this field
    <input name="email" type="email" autocomplete="email" />
    <input
      type="hidden"
      name="token"
      nonce="rAnD0m-VaLuE"
      autocomplete="email-verification-token"
    />
    You must use the autocomplete attributes as shown above.
  • When the user enters their email address into the input field, the browser triggers the process to generate a token
  • The token is stored in the hidden input and submitted to the server when the user completes the form
  • On the server, you use the email, the nonce, and the site URL to verify the token using this library

Verifying the token

Basic verification

Pass the email the user submitted, the token, the nonce from the session, and your site URL as the audience to verify the email address.

A successful result returns an object with an ok property set to true and a value property with the details of the verifcation. If the result is unsuccessful for any reason, then ok will be false and there will be an error property with the details of the error.

import { verifyEmailToken } from "email-verification-api";

const result = await verifyEmailToken({
  token: tokenFromBrowser,
  nonce: nonceForSession,
  email: "[email protected]",
  audience: "https://rp.example.com",
});

if (result.ok) {
  console.log(`${result.value.email} is verified by ${result.value.issuer}`);
} else {
  console.error(result.error);
}

Timeouts and cancellation

To stop verification from going on too long if external services aren't available, you can pass a timeout for the whole process and timeouts for each individual remote call. You can also pass an abort signal to control cancelling the process yourself.

All request timeouts are disabled unless you configure them. This example uses a recommended timeout profile for interactive verification and an abort signal that can cancel the entire operation.

import { verifyEmailToken } from "email-verification-api";

const controller = new AbortController();

// Call controller.abort() if the client disconnects or verification is no longer needed.
const result = await verifyEmailToken({
  token: tokenFromBrowser,
  nonce: nonceForSession,
  email: "[email protected]",
  audience: "https://rp.example.com",
  signal: controller.signal,
  timeoutMs: 10_000,
  timeouts: {
    resolveTxtMs: 2_000,
    resolveHostMs: 2_000,
    metadataFetchMs: 4_000,
    jwksFetchMs: 4_000,
  },
});

if (!result.ok) {
  if (result.error.code === "REQUEST_ABORTED") {
    // The caller cancelled verification.
  } else if (result.error.code === "REQUEST_TIMEOUT") {
    // The overall or active operation deadline expired.
  }
}

Express example

import { verifyEmailToken } from "email-verification-api";

app.post("/emails", async (req, res) => {
  const { email, token } = req.body;
  const nonce = req.session.nonce;
  const audience = `${req.protocol}://${req.host}`;

  if (!token) {
    // Return a response from your OTP or magic-link fallback flow here.
    return;
  }

  const controller = new AbortController();
  const abortVerification = () => controller.abort();

  // Cancel verification if the client connection closes.
  res.once("close", abortVerification);

  const result = await verifyEmailToken({
    email,
    token,
    audience,
    nonce,
    signal: controller.signal,
    timeoutMs: 10_000,
    timeouts: {
      resolveTxtMs: 2_000,
      resolveHostMs: 2_000,
      metadataFetchMs: 4_000,
      jwksFetchMs: 4_000,
    },
  }).finally(() => res.off("close", abortVerification));

  if (result.ok) {
    console.log(`${result.value.email} is verified by ${result.value.issuer}`);
  } else if (result.error.code === "REQUEST_ABORTED") {
    // The client disconnected or the caller otherwise cancelled verification.
  } else if (result.error.code === "REQUEST_TIMEOUT") {
    // The overall or active operation deadline expired.
  } else {
    console.error(result.error);
    // Fall back to another email verification method.
  }
});

API reference

verifyEmailToken() needs four values:

| Property | Meaning | | ---------- | ------------------------------------------------------------- | | token | The complete SD-JWT+KB presentation returned by the browser. | | nonce | The exact nonce previously bound to this application session. | | email | The email address the application expects to verify. | | audience | The relying party's absolute HTTP(S) origin. |

Email comparison is case-insensitive and the nonce comparison is exact and case-sensitive. The audience must serialize to an origin: paths, query strings, fragments, and credentials are rejected.

There are optional arguments to verifyEmailToken too. These are:

| Property | Default | Meaning | | ----------------------- | ------------------------------ | ---------------------------------------------------- | | maxTokenAgeSeconds | 300 | Maximum age of both the EVT and KB-JWT. | | clockToleranceSeconds | 60 | Clock skew allowed for age and future issue times. | | fetch | global fetch | Fetch implementation used for metadata and JWKS. | | resolveTxt | node:dns/promises.resolveTxt | DNS TXT resolver. | | resolveHost | node:dns/promises.lookup | Address resolver used before each issuer request. | | now | () => Date.now() | Clock returning Unix time in milliseconds. | | signal | none | Cancels verification when its controller is aborted. | | timeoutMs | null | Overall verification timeout in milliseconds. | | timeouts | null | Per-operation timeout values in milliseconds. |

The timeouts object supports four independent limits:

| Property | Default | Meaning | | ----------------- | ------- | ------------------------------------------ | | resolveTxtMs | null | Timeout for the DNS TXT lookup. | | resolveHostMs | null | Timeout for each hostname safety lookup. | | metadataFetchMs | null | Timeout for fetching and reading metadata. | | jwksFetchMs | null | Timeout for fetching and reading the JWKS. |

Omitted timeout options and values set to null impose no limit. Timeout numbers must be positive finite integers in milliseconds. timeoutMs limits the entire verification call; before each external operation, the smaller of its configured timeout and the remaining overall time applies.

You can explicitly opt out at either level:

await verifyEmailToken({
  token,
  nonce,
  email,
  audience,
  timeoutMs: null,
  timeouts: { jwksFetchMs: null },
});

await verifyEmailToken({
  token,
  nonce,
  email,
  audience,
  timeouts: null,
});

Exact age and tolerance boundaries are accepted. Both age-related timing options must be nonnegative numbers.

On success, the result contains authenticated values:

type VerifiedEmail = {
  email: string;
  issuer: string;
  audience: string;
  issuedAt: {
    evt: number;
    keyBinding: number;
  };
  claims: EvtClaims;
};

Verification order

verifyEmailToken() performs all the required verifications of the token in the following order. If any stage fails the function returns with an error object that describes what failed.

  1. parseToken() validates the SD-JWT token and resolves disclosures.
  2. validateExpectedValues() rejects unexpected claims and stale tokens before network access.
  3. verifyDnsDelegation() confirms that the email domain delegates to the claimed issuer. That is, the email verification is correctly served by the URL listed as the iss property in the token payload.
  4. verifyIssuerSignature() retrieves metadata and JWKS from the issuer, then authenticates the EVT.
  5. verifyKeyBinding() uses the authenticated cnf.jwk to verify the KB-JWT and checks its sd_hash.

Errors and Results

Every verification stage returns Result or Promise<Result> rather than throwing for invalid input, malformed tokens, or dependency failures:

type Result<T, E = VerificationError> =
  { ok: true; value: T } | { ok: false; error: E };

type VerificationError = {
  stage: VerificationStage;
  code: VerificationErrorCode;
  message: string;
  cause?: string;
};

Use stage and code for application logic. message is a descriptive log message. cause, when present, is a normalized description of the underlying failure and should not be shown directly to end users.

The stages are input, parse, expected-values, dns, issuer, and key-binding.

The error codes are:

INVALID_INPUT                    TOKEN_MALFORMED
DISCLOSURE_INVALID               EMAIL_MISMATCH
EMAIL_NOT_VERIFIED               NONCE_MISMATCH
AUDIENCE_MISMATCH                TOKEN_EXPIRED
TOKEN_NOT_YET_VALID              DNS_LOOKUP_FAILED
DNS_DELEGATION_MISSING           DNS_DELEGATION_AMBIGUOUS
ISSUER_MISMATCH                  METADATA_FETCH_FAILED
METADATA_INVALID                 JWKS_FETCH_FAILED
JWKS_INVALID                     ALGORITHM_UNSUPPORTED
EVT_SIGNATURE_INVALID            KB_SIGNATURE_INVALID
SD_HASH_MISMATCH                 REQUEST_ABORTED
REQUEST_TIMEOUT

REQUEST_ABORTED means the caller's AbortSignal cancelled verification. REQUEST_TIMEOUT means the overall deadline or the active operation's deadline expired.

The package also exports ok(), err(), isOk(), isErr(), the error schemas, and their inferred TypeScript types.

Dependency injection

Pass network and clock implementations per verification call when a runtime, test, or application needs different behavior:

import { lookup, resolveTxt } from "node:dns/promises";
import { verifyEmailToken } from "email-verification-api";
import type { ResolveHost } from "email-verification-api";

declare const tokenFromBrowser: string;
declare const nonceForSession: string;

const resolveHost: ResolveHost = async (hostname) => {
  const addresses = await lookup(hostname, { all: true, verbatim: true });
  return addresses.flatMap(({ address, family }) =>
    family === 4 || family === 6 ? [{ address, family }] : [],
  );
};

const result = await verifyEmailToken({
  token: tokenFromBrowser,
  nonce: nonceForSession,
  email: "[email protected]",
  audience: "https://rp.example.com",
  fetch: globalThis.fetch,
  resolveTxt,
  resolveHost,
  now: () => Date.now(),
  maxTokenAgeSeconds: 300,
  clockToleranceSeconds: 60,
});

resolveHost has a deliberately small cross-runtime shape:

type ResolveHost = (
  hostname: string,
) => Promise<readonly { address: string; family: 4 | 6 }[]>;

The library calls resolveHost immediately before each metadata and JWKS request. It calls resolveHost twice when both URLs use the same hostname. It does so to verify that hosts resolve to valid, globally reachable IP addresses.

Network security and runtime behavior

Each address must match its declared family. Each address must also be globally reachable. Verification fails if the answer is empty, malformed, too large, or mixes public and private addresses.

HTTP requests

Issuer requests use credentialless GET. Redirect handling is set to error. Responses marked as redirected are rejected.

Cancellation and timeouts

The signal for each Fetch request remains active while its JSON response body is read, so cancellation and timeouts actively abort both the request and body reading.

Node's DNS promise APIs do not accept an AbortSignal; verification still returns promptly when a DNS operation is cancelled or times out, but the underlying DNS work may finish later and its result is ignored.

Rejected promises, thrown values, and invalid dependency responses become failed Results.

Developing

To work on the library first clone it from GitHub:

git clone https://github.com/philnash/email-verification-api.git
cd email-verification-api

Install the dependencies:

npm install

Ensure that the tests pass:

npm test

Before making a pull request, ensure that all the checks (lint, format, types, tests) pass:

npm run check

License

MIT