WhatsApp Gateway
Guides

Sign in with WhatsApp

Add "Sign in with WhatsApp" to your app — a standard OAuth 2.1 / OIDC login where users prove control of their WhatsApp number.

Sign in with WhatsApp lets your users log in to your app by proving they control a WhatsApp number — no password page. Under the hood it is a completely standard OAuth 2.1 / OpenID Connect authorization-code flow with PKCE, so you integrate it with any off-the-shelf OIDC client library. This guide is for a developer adding the button to a third-party ("relying") app.

What your users see

When a user clicks your Sign in with WhatsApp button, they land on a branded consent page that shows your app's name and a short instruction:

  • DM mode — "Send login 483920 to +62 812-xxxx". The page offers a wa.me deep link and a QR code that pre-fills the message. The user sends it from their own WhatsApp, the bot replies to confirm, and the browser redirects back to your app signed in.
  • Group mode — "Mention the bot with login 483920 in Acme Support". The user posts the code in a specific group, which additionally proves they are a member of that group.

The 6-digit code is short-lived (10 minutes) and single-use. That's the whole login: no password, no OTP typed into a form — the WhatsApp message is the consent.

Honest security note. This factor proves the user controls a WhatsApp number — it does not prove intent. A determined attacker can start a login, get the code, and socially engineer a victim into sending it (a code-relay attack). We mitigate this: the bot's confirmation names your app and offers a STOP reply to abort, the code expires fast, and both the page and the chat show the same app identity so a mismatch is visible. But we do not market this as phishing-proof MFA. For money movement or sensitive PII, treat it as one factor and layer a second (e.g. a device check or step-up). Requiring acr=wa:group (below) shrinks the attack surface to known group members.

Prerequisites: create a sign-in app

The person who owns the WhatsApp number (the gateway customer) creates the sign-in app in the dashboard. You, the relying-app developer, receive the resulting credentials. In the dashboard they open Sign in with WhatsApp, select New app, and fill in the form. A preview shows what users will see.

  1. Under App details, enter the App name and optionally a Logo URL. Both appear on the consent page and in the bot's confirmation message, so users recognise who they're signing in to. Optionally set the Bot name to the WhatsApp profile name of the number, so users recognise the bot they're messaging (and can pick it from the @-mention suggestions in group mode).
  2. Under WhatsApp number, choose the Number people message. That connected session becomes the "bot" users message.
  3. Under How users verify, turn on Direct message, Group membership, or both. Group membership requires choosing the Group users must be in.
  4. Set the Login keyword: the word users type before the code (default login, for example masuk). Messages to this number that start with this word are intercepted for login and never reach the customer's own webhooks.
  5. Under Redirect URIs, add the Allowed redirect URIs: exact-match, https only (http://localhost allowed for development), no URL fragments.
  6. Under What your app receives, pick the scopes (see Scopes and claims).
  7. Under App type and how long sign-ins last, choose the App type: Server app (a confidential client with a secret) or Browser or mobile app (a public client with no secret, PKCE only). The same section sets the Access token lifetime and Stay signed in for (the refresh token lifetime).
  8. Select Create app.

On create they get a client_id and, for a server app, a client_secret shown once. Copy it then, because it can't be read again. They can create a new one later with Rotate secret on the app's Settings tab. Hand these values to your app.

After the secret dialog closes, the dashboard opens the app's Integration tab. It lists the discovery URL, client ID, and redirect URI, and generates a ready-to-run version of the example below in Node.js or Python, pre-filled with the app's values. Try sign-in opens the real consent page in a new tab so the owner can walk through the flow their users will see.

The endpoints (via discovery)

We are a standard OIDC provider. Point your library at the discovery document and it learns every endpoint automatically:

GET https://<gateway>/.well-known/openid-configuration

That advertises the authorization, token, userinfo, and revocation endpoints, the JWKS URI, and our capabilities:

CapabilityValue
response_types_supported["code"]
grant_types_supported["authorization_code", "refresh_token"]
code_challenge_methods_supported["S256"] (PKCE mandatory)
id_token_signing_alg_values_supported["EdDSA"]
subject_types_supported["public"]
acr_values_supported["wa:dm", "wa:group"]
scopes_supportedopenid, profile, phone, wa:group, offline_access

The signing keys are published at the JWKS URI so your library can verify id_tokens and access tokens offline. Because everything is RFC-shaped (authorization code + PKCE S256, public subjects, EdDSA JWTs), any compliant OIDC library works — there is nothing WhatsApp-specific to special-case except the optional acr_values.

Worked example (Node, openid-client)

This is a complete end-to-end integration using the standard openid-client library — the same one the dashboard's Integration tab generates for Node.js. It builds the authorize URL with PKCE, state, and nonce, handles the redirect back, exchanges the code, and reads the claims.

npm install openid-client express
import express from "express";
import * as client from "openid-client";

const ISSUER = "https://<gateway>";
const CLIENT_ID = "<your client_id>";
const redirect_uri = "https://your-app.example.com/callback";

// 1. Discover the provider (endpoints, JWKS, supported features).
const config = await client.discovery(
  new URL(`${ISSUER}/.well-known/openid-configuration`),
  CLIENT_ID,
  {
    // Confidential client — read the secret from an env var, never inline it:
    client_secret: process.env.WA_CLIENT_SECRET,
    // Public client instead? Drop client_secret and use:
    // token_endpoint_auth_method: "none", // PKCE only
  },
);

const app = express();

// 2. Kick off login: PKCE + state + nonce, then redirect to WhatsApp sign-in.
app.get("/login", async (req, res) => {
  const code_verifier = client.randomPKCECodeVerifier();
  const code_challenge = await client.calculatePKCECodeChallenge(code_verifier);
  const state = client.randomState();
  const nonce = client.randomNonce();
  req.session = { code_verifier, state, nonce }; // persist for the callback

  const url = client.buildAuthorizationUrl(config, {
    redirect_uri,
    scope: "openid profile phone offline_access",
    code_challenge,
    code_challenge_method: "S256",
    state,
    nonce,
    // Optional. Omit to use the app's default mode. Force group verification with:
    // acr_values: "wa:group",
  });
  res.redirect(url.href);
});

// 3. Handle the callback: exchange the code, verify the id_token, read claims.
app.get("/callback", async (req, res) => {
  const { code_verifier, state, nonce } = req.session;
  const tokens = await client.authorizationCodeGrant(
    config,
    new URL(req.url, "https://your-app.example.com"),
    {
      pkceCodeVerifier: code_verifier,
      expectedState: state,
      expectedNonce: nonce,
    },
  );

  // id_token signature, iss, aud, exp, and nonce are verified for you.
  const claims = tokens.claims(); // sub, acr, amr, auth_time, ...
  const userinfo = await client.fetchUserInfo(
    config,
    tokens.access_token,
    claims.sub,
  );

  // Persist tokens.refresh_token (see "Refresh tokens" below) if you requested
  // offline_access, then start your own app session for this user.
  res.json({ claims, userinfo });
});

app.listen(3000, () =>
  console.log("Sign in with WhatsApp: http://localhost:3000/login"),
);

That's the whole flow: /login sends the user to the consent page; they send the WhatsApp message; the browser comes back to /callback with a code; you exchange it and get verified claims.

Choosing DM vs group per login

If the sign-in app enables both verification methods, pick per request with acr_values:

  • acr_values: "wa:dm" — DM verification.
  • acr_values: "wa:group" — group verification (proves membership).
  • Omitted — defaults to DM if enabled, otherwise group.

The acr actually satisfied is echoed in the id_token, so you can require claims.acr === "wa:group" before trusting group membership. Requesting a mode the app doesn't enable returns an invalid_request error.

Scopes and claims

Request scopes space-separated in the scope parameter. Each scope unlocks a set of claims, available on the id_token and from /userinfo.

ScopeClaims granted
openidsub, acr, amr: ["whatsapp"], auth_time — always required
profilename (WhatsApp push/business name)
phonephone_number (E.164), phone_number_verified: true, wa_jid
wa:groupwa_group_verified, wa_group_id, wa_group_name
offline_accessissues a refresh_token

What the claims mean:

  • sub — the user's canonical WhatsApp LID, such as 107082225311887@lid. It is stable across sign-in apps and therefore correlatable across relying parties by design. Use it as your primary key for the account.
  • name — resolved fresh at each login from WhatsApp, so a display-name change is reflected next time.
  • phone_number / wa_jid — request phone only if you actually need the number. The subject is already a public WhatsApp LID, so phone claims should be requested for product need rather than account correlation.
  • acr — "wa:dm" or "wa:group", the verification actually performed.
  • amr — always ["whatsapp"].

The wa_group_* claims appear only when acr is wa:group — that is, the user verified inside the pinned group. Requesting the wa:group scope on a DM login yields no group claims, because no membership was proven. Always check acr === "wa:group" before relying on wa_group_verified.

Refresh tokens

Request the offline_access scope to receive a refresh_token, which you exchange at the token endpoint for fresh access and id tokens without sending the user back through WhatsApp.

Rotation is mandatory. Every refresh returns a new refresh token and invalidates the one you sent. So:

  1. Always persist the newest refresh token from each response, replacing the previous one.
  2. Never use a refresh token twice.
// Refresh later, storing the rotated token:
const refreshed = await client.refreshTokenGrant(config, storedRefreshToken);
storedRefreshToken = refreshed.refresh_token; // persist the new one immediately

Reuse kills the whole family. If a consumed refresh token is presented again, we treat it as a stolen-token replay and revoke the entire chain of tokens derived from that grant. The legitimate user is then signed out and must log in again. This is why you must persist the rotated token atomically — a crash between "receive new token" and "save it" can leave you presenting a stale one. Access tokens are short-lived (15 minutes by default) and self-contained; there is no separate access-token store to keep in sync.

Revoking access

Two ways a grant ends:

  • Your app revokes it (RFC 7009). Post the refresh or access token to the revocation endpoint (your library exposes this — e.g. client.tokenRevocation(config, token)). Do this at your app's logout.
  • The app owner revokes it from the app's Signed-in users tab in the dashboard, with Revoke for one user or Sign everyone out for all of them. This immediately kills the user's refresh tokens; their access token then dies at its short TTL.

Either way the user has to sign in through WhatsApp again to get a new grant.

Troubleshooting & FAQ

"The code expired." Login codes live for 10 minutes and are single-use. If the user is slow, or reloads the consent page, start a fresh login from your /login route — you can't reuse an old code.

invalid_request / redirect mismatch. Redirect URIs are matched exactly — the redirect_uri you send to /authorize must byte-for-byte equal one registered on the app (scheme, host, port, path, trailing slash all count; fragments are rejected). Register every URI you use, including your localhost dev URL.

invalid_grant at the token endpoint. The authorization code is single-use and lives ~60 seconds. This error also means the code_verifier didn't match the code_challenge (a PKCE mismatch — usually a lost or wrong code_verifier in session), or the code was already redeemed. Re-run the flow from /login.

invalid_grant when refreshing. The refresh token was already used (rotation), was revoked, or you presented one from a family we killed after a reuse. Persist the newest token after every refresh; if you hit this, send the user back through login.

User removed from the group. Group-mode logins require the message to arrive in the pinned group with the bot mentioned, and we cross-check membership. A user who has left the group can't complete a wa:group login — they need to be re-added, or use DM mode if the app enables it. Existing sessions aren't torn down mid-life, but the next acr=wa:group login will fail.

The bot never confirms / no reply in WhatsApp. Check the code and command match exactly (login 483920, case-insensitive, the app's configured command word). Wrong or expired codes are silently dropped after a few attempts to avoid acting as a brute-force oracle — so no reply can also mean the per-sender attempt limit was hit. Wait and start a fresh login.

"This isn't me." If a user sees a login they didn't start, the consent page has a cancel action and the bot accepts a STOP reply — both abort the flow. That's the built-in defence against the code-relay attack described at the top.

On this page