WeChat Mini Program Phone Account Binding Guide
This guide is for developers who need to understand or reuse the mechanism that merges a WeChat Mini Program user into an existing InfiniSynapse account via phone number. It explains how a mini program that acts purely as an "account entry + relay station" connects, on the server side, a WeChat user to an InfiniSynapse account so both share the same account system.
Context: the AI Shopping (price-comparison assistant) mini program. The actual AI work (comparing prices, reading reviews, adding to cart) runs in the InfiniSynapse browser extension on PC Chrome — it cannot run on a phone. The mini program only handles login, obtaining the phone number, merging the account, and guiding the user to the PC.
About the account system: InfiniSynapse now uses self-hosted authentication (the account center is the
infini-proxyservice, RS256 JWT) and no longer depends on any third-party user pool. The mini program login returns exactly the same access / refresh tokens as the web client — there is no separate "mini program session".
1. How it works
The key difference first: there is no such thing as a "mini program account". Mini program login is just one of the account center's login methods (provider wechat_miniapp), and it produces the platform's unified access token.
In the ideal case the whole flow takes a single request — the client uses a getPhoneNumber button right on the login page and submits the wx.login code together with the phone authorization code:
Mini Program Account center WeChat Open Platform
│ │ │
│ ① wx.login() → code │ │
│ ② getPhoneNumber → phoneCode │ │
│ ③ POST /miniapp/login ───────▶│ │
│ { code, phoneCode } │ ④ code2session(code) ────────▶│
│ │ ◀── openId / unionId ─────────│
│ │ ⑤ getuserphonenumber ────────▶│
│ │ ◀──────── phoneNumber ────────│
│ │ ⑥ resolve: openid → unionId │
│ │ → phone → create │
│ ◀── accessToken / refreshToken │ │
- WeChat login: the mini program calls
wx.login()to get a temporarycode. - Authorize phone: the user taps an
open-type="getPhoneNumber"button; WeChat returns a dynamic tokencode(the new "phone number quick verification"), submitted asphoneCode. - Exchange identity: the account center calls WeChat
jscode2sessionwith thecodeto obtainopenId(unique within the mini program) andunionId(unique under the Open Platform subject; empty if the mini program is not bound to the Open Platform), then callsgetuserphonenumberwith the mini program's globalaccess_tokento turnphoneCodeinto a real phone number. - Resolve identity:
(openId, unionId, phone)goes into the shared identity resolver, which decides which account this identity belongs to (see the next section). - Issue session: access / refresh tokens identical in shape to the web client's.
The phone number is still the primary link between the mini program user and an existing account: if the user registered on PC with the same number, this login merges straight into that account and shows the same data — no duplicate registration.
Why no account is created when the phone is missing
If the client submits only code without phoneCode:
- the openid is already known → normal login;
- the openid is unknown → returns
{ needBindPhone: true }, issuing no token and creating no account. The client must prompt for phone authorization and retry (the retry needs a freshwx.login; the code is single-use).
This is deliberate. wx.login only yields an openid; creating an account at that point means that when the user later authorizes their phone number, you discover it already has an account — now there are two, and the only remedy is an after-the-fact merge plus business-data migration. That is far more expensive than one extra interaction.
2. Identity resolution: whose identity is this
The mini program does not decide account ownership itself. All login methods (phone code, WeChat QR, WeChat Official Account, mini program, Alipay, email, GitHub, Google) share one resolver, matching from strongest to weakest evidence:
| Order | Evidence | Notes |
|---|---|---|
| 1 | exact (provider, provider_uid) | i.e. ('wechat_miniapp', openId); returning users hit this |
| 2 | union_id | only within the WeChat family (wechat_*), so mini program / PC QR / Official Account are one account |
| 3 | verified phone | ('phone', number) with verified=1; this is the path that merges into an existing PC account |
| 4 | verified email | not used in the mini program scenario |
| 5 | nothing matched | create an account, registering both the openid and the phone as identities |
Two important constraints:
- Only phone numbers / emails already verified by the third party participate in merging. A number from
getPhoneNumberwas verified by WeChat, so it qualifies; a value the user typed into a form must never qualify — otherwise entering someone else's number would merge you into their account. - The mini program must be bound to the WeChat Open Platform, otherwise WeChat returns no
unionidand the same person arriving via mini program and via PC QR ends up with two accounts (unless both sides share a phone number, which rule 3 catches). The server logs a warning in this case.
3. Prerequisites
Environment variables
# WeChat mini program credentials (Mini Program console → Development → Dev settings)
WX_MINIAPP_APPID=wxb2593c4dd46cf539
WX_MINIAPP_SECRET=your_mini_program_secret
# Login-method allowlist; must include wechat_miniapp (the default already does)
AUTH_PROVIDERS=phone,wechat_open,wechat_mp,wechat_miniapp,alipay,email_code,email_password,github,google,username_password
# Local dev without credentials: when on, use mock data instead of the real WeChat APIs
MINIAPP_MOCK=false
# Where to send users on PC (all have defaults)
MINIAPP_REGISTER_URL=https://infinisynapse.cn
MINIAPP_PC_SHOPPING_URL=https://infinisynapse.cn/apps/straight-man-shopping
MINIAPP_PLUGIN_DOC_URL=https://infinisynapse.cn/en/docs/Chrome%20Plugin%20Install
# The app service that runs the shopping agent
MINIAPP_APP_SERVICE_URL=https://app.infinisynapse.cn
MINIAPP_AGENT_LANG=zh_CN
# Reuse existing account-center config: AUTH_ISSUER, JWT_PRIVATE_KEYS_B64, REDIS_*, MySQL
AUTH_ISSUER/JWT_PRIVATE_KEYS_B64: needed for the account center to sign access tokens; shared with every other login method, not mini-program-specific.REDIS_*: caches the WeChat globalaccess_token.
Dependencies
- The account center service (
infini-proxy), with MySQL + Redis; - No third-party user-pool SDK — users live in the local
users/user_identitiestables.
4. Sessions: use the platform token, don't mint another
Previously the mini program signed its own typ:'miniapp' JWT and hand-rolled a requireSession check. That parallel mechanism has been removed: the mini program now carries an ordinary access token, validated by the global guard.
Authorization: Bearer <accessToken>
The benefit is direct: one fewer parallel session mechanism means one fewer place where invalidation, revocation, and expiry have to be patched separately. When the access token expires, refresh through the account center's standard refresh endpoint.
5. Server-side implementation
5.1 wx.login → openId / unionId
const url =
`https://api.weixin.qq.com/sns/jscode2session?appid=${appId}` +
`&secret=${appSecret}&js_code=${code}&grant_type=authorization_code`
const data = await (await fetch(url)).json()
// data.openid / data.unionid
5.2 phoneCode → phone
New-style quick verification: the getPhoneNumber button callback returns a code, and the server exchanges it for the phone number using the mini program's global access_token.
The access_token is obtained via stable_token mode and cached in Redis (expire 5 minutes early to avoid boundary issues):
// Use the cached value if present, otherwise request stable_token and setex it back into Redis
const res = await fetch('https://api.weixin.qq.com/cgi-bin/stable_token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'client_credential', appid, secret }),
})
Then exchange access_token + code for the number, and normalize it to E.164 (+8613800000000). Without normalization the same number gets stored in two formats, the unique index becomes useless, and phone-based merging silently misses:
const res = await fetch(
`https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) },
)
const data = await res.json()
const raw = data.phone_info?.purePhoneNumber || data.phone_info?.phoneNumber
const phone = normalizePhone(raw, 'CN')
Only the new
codemode is implemented. The legacyencryptedData/ivare not decrypted.
5.3 Hand off to the shared flow
The provider only proves that "this openid (and optional phone number) is genuine", producing a VerifiedIdentity; AuthFlowService does the rest:
// With a phone: done in one shot, and the phone participates in merging
const result = await flow.login('wechat_miniapp', { code, phoneCode }, ctx)
// Without a phone: only accept existing accounts; return null instead of creating one
const existing = await flow.loginIfExists('wechat_miniapp', { code }, ctx)
if (existing === null) return { needBindPhone: true }
5.4 Binding the phone after login
If the user did not authorize at login and adds the number later, use POST /miniapp/bind-phone. The difference from passing phoneCode at login matters: on the login path the phone participates in merging (it can pull you into an existing account), whereas here the account is already fixed and the phone can only be appended to it. So if the number already belongs to someone else, the only correct response is a conflict — merging two accounts that both hold data is not something a binding endpoint should do.
6. Data model
Ownership is determined by user_identities, where an identity is (provider, provider_uid):
| provider | provider_uid | Notes |
|---|---|---|
wechat_miniapp | openId | the union_id column stores the UnionID, used for WeChat-family unification |
phone | E.164 number | must be verified=1 to participate in merging |
The miniapp_bindings table has been reduced to an auxiliary record for the shopping feature: it holds only the API Key cache and login stats. Identity resolution does not depend on it, and a failed write does not block login:
| Field | Description |
|---|---|
open_id | Unique user id within the mini program; unique index |
union_id | Unique under the WeChat Open Platform subject, optional |
phone | Redundant copy, may be empty |
authing_user_id | Legacy column name that stores the local users.id. The migration reused the original user pool's sub as the new user_id, so the value range is identical and the column's contents never changed; renaming it would mean DDL plus read/write changes across four codebases, for nothing but a nicer name |
api_key | The shopping-service API Key (sk-xxx) issued for this account, see §8 |
login_count / first_login_at / last_login_at | Login stats |
7. Endpoints (global prefix /api)
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/miniapp/login | public | { code, phoneCode? } → { accessToken, refreshToken, expiresIn, registered, user }, or { needBindPhone: true } |
| POST | /api/miniapp/bind-phone | Bearer | { code }, append a phone number to the current account |
| GET | /api/miniapp/profile | Bearer | Current user profile (phone is masked) |
| GET | /api/miniapp/handoff | Bearer | PC handoff info (QR content / extension install doc / register URL) |
| GET | /api/miniapp/browser-session | Bearer | Check PC Chrome extension connectivity |
| GET | /api/miniapp/shopping/sites | Bearer | Selectable shopping sites for the task form |
| POST | /api/miniapp/shopping/task | Bearer | Submit a shopping task, returns { taskId } |
| GET | /api/miniapp/shopping/task | Bearer | Poll task result (reasoning trace + final report) |
| GET | /api/miniapp/shopping/snapshot | Bearer | Proxy task browser screenshots for wx.downloadFile |
8. After merging: server-side API Key issuance
Once you have the userId, the mini program can call InfiniSynapse's shopping capabilities as that account without obtaining the user's access token for the app service: the account center issues an sk- prefixed API Key for that userId server-side, caches it on the binding record, and uses it to call app services (e.g. app.infinisynapse.cn).
The key hangs off userId rather than the openid — the same person arriving from the mini program and from PC is one account, so there is no reason to give them two keys.
async function ensureApiKey(userId: string): Promise<string> {
const binding = await bindingRepo.findOne({ where: { userId } })
if (binding?.apiKey) {
// Confirm it is still valid (not deleted by the user in the console)
const owner = await apiKeyService.getUserIdByApiKey(binding.apiKey).catch(() => undefined)
if (owner === userId) return binding.apiKey
}
const created = await apiKeyService.createApiKey({ userId, name: 'Shopping Mini Program' })
if (binding) await bindingRepo.update({ id: binding.id }, { apiKey: created.apiKey })
return created.apiKey
}
9. Key constraints and gotchas
- Account identity is consistent by construction: the account signed into the extension, the API Key's userId, and the account the mini program merges into are now the same
users.id. Previously this had to be kept aligned through an external user pool's sub, and local dev environments diverged; with a single account system that class of problem is gone. - Normalize the phone before comparing: writes and lookups go through the same E.164 normalization, otherwise
13800000000and+8613800000000count as two different numbers. - Bind the mini program to the Open Platform: without it there is no
unionidand WeChat-family unification cannot happen. - Only the new getPhoneNumber is implemented: legacy
encryptedData/ivare not decrypted. - access_token caching: the WeChat global
access_tokenis obtained viastable_tokenand cached in Redis to avoid rate limits from frequent requests. - Never create an account when the phone is missing: see the end of §1; this is what prevents one person from ending up with two accounts.
10. Local development (without WeChat credentials)
With MINIAPP_MOCK=true:
code2sessionderives a stable mockopenId(mock_openid_<hex>) from thecode, so you can iterate against the same "user";getPhoneNumberreturns a fixed phone13800000000;- The shopping endpoints return mock tasks and
sk-mock-miniapp.
Identity resolution, account creation, and token issuance all run for real, so you can walk the whole "login → authorize phone → merge account → submit shopping task" chain in WeChat DevTools without real credentials. Turn off mock and verify with a real device or trial version after configuring a real WX_MINIAPP_SECRET.