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 483920to +62 812-xxxx". The page offers awa.medeep 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 483920in 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.
- 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).
- Under WhatsApp number, choose the Number people message. That connected session becomes the "bot" users message.
- Under How users verify, turn on Direct message, Group membership, or both. Group membership requires choosing the Group users must be in.
- Set the Login keyword: the word users type before the code (default
login, for examplemasuk). Messages to this number that start with this word are intercepted for login and never reach the customer's own webhooks. - Under Redirect URIs, add the Allowed redirect URIs: exact-match,
httpsonly (http://localhostallowed for development), no URL fragments. - Under What your app receives, pick the scopes (see Scopes and claims).
- 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).
- 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-configurationThat advertises the authorization, token, userinfo, and revocation endpoints, the JWKS URI, and our capabilities:
| Capability | Value |
|---|---|
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_supported | openid, 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 expressimport 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.
| Scope | Claims granted |
|---|---|
openid | sub, acr, amr: ["whatsapp"], auth_time — always required |
profile | name (WhatsApp push/business name) |
phone | phone_number (E.164), phone_number_verified: true, wa_jid |
wa:group | wa_group_verified, wa_group_id, wa_group_name |
offline_access | issues a refresh_token |
What the claims mean:
sub— the user's canonical WhatsApp LID, such as107082225311887@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— requestphoneonly 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:
- Always persist the newest refresh token from each response, replacing the previous one.
- 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 immediatelyReuse 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.