EXPERIMENTAL WIP ONE WHATSAPP CONNECTION NO SLA

Server verification

Verification must reconstruct a trusted identity from the signed token. Decoding a JWT or trusting browser claims is not verification.

Recommended package

@wamenjs/server@0.1.0 is the supported backend verifier. It owns discovery, JWKS caching, ES256 verification, issuer, exact audience, lifetime, subject, authentication method, and optional phone invariants.

Install the Wamen server verifier
npm install @wamenjs/server
verify-wamen.ts
import { createWamenVerifier } from "@wamenjs/server";

const APP_ORIGIN = "https://app.example";

const verifier = createWamenVerifier({
  issuer: "https://wamen.link",
  audience: "origin:" + new URL(APP_ORIGIN).origin,
});

export async function verifyWamenIdentity(idToken: string) {
  if (typeof idToken !== "string" || idToken.length === 0 || idToken.length > 16_384) {
    throw new Error("Invalid Wamen identity token");
  }
  return verifier.verify(idToken);
}

Direct jose reference

The complete implementation below documents the checks enforced by @wamenjs/server and provides a framework-independent fallback for runtimes that cannot install the package.

Install jose for the direct verifier
npm install jose
verify-wamen-direct.ts
import { createRemoteJWKSet, jwtVerify } from "jose";

const ISSUER = "https://wamen.link";
const APP_ORIGIN = "https://app.example";
const AUDIENCE = "origin:" + new URL(APP_ORIGIN).origin;
const JWKS = createRemoteJWKSet(
  new URL(ISSUER + "/.well-known/jwks.json"),
);
const SUBJECT = /^wam_[A-Za-z0-9_-]{43}$/;
const REQUIRED = ["iss", "aud", "sub", "iat", "exp", "jti", "amr"];

export async function verifyWamenIdentity(idToken: string) {
  if (typeof idToken !== "string" || idToken.length === 0 || idToken.length > 16_384) {
    throw new Error("Invalid Wamen identity token");
  }

  const now = new Date();
  const { payload, protectedHeader } = await jwtVerify(idToken, JWKS, {
    algorithms: ["ES256"],
    issuer: ISSUER,
    audience: AUDIENCE,
    requiredClaims: REQUIRED,
    currentDate: now,
    clockTolerance: 0,
  });

  if (
    protectedHeader.alg !== "ES256" ||
    typeof protectedHeader.kid !== "string" ||
    protectedHeader.kid.length === 0 ||
    payload.iss !== ISSUER ||
    payload.aud !== AUDIENCE ||
    typeof payload.sub !== "string" ||
    !SUBJECT.test(payload.sub) ||
    !Number.isSafeInteger(payload.iat) ||
    !Number.isSafeInteger(payload.exp) ||
    Number(payload.exp) - Number(payload.iat) !== 600 ||
    Number(payload.iat) > Math.floor(now.getTime() / 1000) + 5 ||
    typeof payload.jti !== "string" ||
    payload.jti.length === 0 ||
    !Array.isArray(payload.amr) ||
    payload.amr.length !== 1 ||
    payload.amr[0] !== "whatsapp"
  ) {
    throw new Error("Invalid Wamen identity claims");
  }

  const hasPhone = Object.hasOwn(payload, "phone_number");
  const hasPhoneVerified = Object.hasOwn(payload, "phone_number_verified");
  if (hasPhone !== hasPhoneVerified) {
    throw new Error("Invalid Wamen phone claims");
  }

  const identity = {
    issuer: ISSUER,
    audience: AUDIENCE,
    subject: payload.sub,
    issuedAt: Number(payload.iat),
    expiresAt: Number(payload.exp),
    jwtId: payload.jti,
    authenticationMethods: ["whatsapp"] as const,
  };

  if (!hasPhone) return identity;
  if (
    typeof payload.phone_number !== "string" ||
    !/^\+[1-9]\d{1,14}$/.test(payload.phone_number) ||
    payload.phone_number_verified !== true
  ) {
    throw new Error("Invalid Wamen phone claims");
  }

  return {
    ...identity,
    phoneNumber: payload.phone_number,
    phoneNumberVerified: true as const,
  };
}

Required checks

FieldRequired value
Protected headeralg=ES256 and a non-empty kid.
issExactly https://wamen.link.
audExactly origin: plus the normalized app origin.
subwam_ plus 43 base64url characters. It is pairwise to the full app origin.
iat and expSafe integer dates with exactly a 600-second lifetime and no expiry tolerance.
jtiA non-empty unique token identifier.
amrExactly ["whatsapp"].
Phonephone_number and phone_number_verified=true must appear together; phone must be E.164.

Map identity to your user

  • Use the verified pairwise subject as the Wamen identity key.
  • Do not use phone as a permanent identity key. It is optional, login-scoped data.
  • Store the relationship in your own user database.
  • Create your own opaque session and set a Secure, HttpOnly cookie.
  • Do not persist the Wamen token as the durable session credential.

Failure behavior

Return a generic 401 to the browser. Keep detailed error classes in sanitized server logs, without logging the token, callback URL, phone number, authorization code, or message body.