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.
npm install @wamenjs/server
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.
npm install jose
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
| Field | Required value |
|---|---|
| Protected header | alg=ES256 and a non-empty kid. |
iss | Exactly https://wamen.link. |
aud | Exactly origin: plus the normalized app origin. |
sub | wam_ plus 43 base64url characters. It is pairwise to the full app origin. |
iat and exp | Safe integer dates with exactly a 600-second lifetime and no expiry tolerance. |
jti | A non-empty unique token identifier. |
amr | Exactly ["whatsapp"]. |
| Phone | phone_number and phone_number_verified=true must appear together; phone must be E.164. |
Map identity to your user
- Use the verified pairwise
subjectas 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.