WeChat QR Login 48001: Account-Type Trap + a Free Workaround
Background: Not Wanting to Spend Another Certification Fee for a Login Button
The website already has GitHub / Google OAuth, and we wanted to add WeChat login. In the WeChat ecosystem, "proper" website scan login goes through the Open Platform 'Website Application', which requires separate developer certification; but we already have a certified Official Account, so we decided to take the Official Account route to save this money—with an added benefit: users who log in via scan automatically become fans of the Official Account.
The classic approach for the Official Account route is parameterized QR codes:
- Server calls
cgi-bin/qrcode/createto generate a temporary QR code withsceneparameter - Display on the webpage, users scan to follow (or scan directly if already following)
- WeChat pushes
subscribe/SCANevents to your configured callback URL, with openid and scene in the event - Server marks the login ticket corresponding to scene as confirmed, frontend polls for the result, login complete
We implemented the entire chain, all 18 local assertions passed (signature verification positive/negative cases, ticket state machine, anti-replay, session forging), deployed online, configured IP whitelist and server settings—and then the first real QR code generation failed.
Incident Scene: 48001 api unauthorized
[wechat-qrcode] Error: [wechat-mp] 创建二维码失败: 48001 api unauthorized
rid: 6a89846c-6a4ab866-02c7a905
The official explanation for 48001 is "api function unauthorized". The subsequent investigation went through four false suspects, each worth noting because search engine answers for 48001 are almost all in these four directions—but in our scenario, none of them held true.
False Suspect One: IP Whitelist / Credential Error
Evidence that it didn't hold is straightforward: access_token was obtained. The error occurred at the qrcode/create step, not at cgi-bin/token. If it were a whitelist or AppSecret issue, it would report 40164 / 40125 when getting the token, and wouldn't reach the QR code creation.
False Suspect Two: Token Cache Trap
We cached access_token in a database single-row table (token is globally unique + daily access limit, multi-machine deployment requires shared cache). This introduced a real risk: if credentials were misconfigured initially, the cache might contain "someone else's" token, and after correcting credentials, the API still uses the old cache.
Clear the cache, force a fresh token with current credentials—still 48001. Ruled out.
(This pitfall is worth noting separately: any token cache table must be manually cleared after changing credentials.)
False Suspect Three: Backend 'API Permission' Page Shows 'Authorized'
The API permission list in the Official Account backend clearly shows the 'Generate Parameterized QR Code' as available. This was the most misleading part of the investigation—the display on the backend page can be inconsistent with actual API permissions, so don't treat it as authoritative.
False Suspect Four: Business Domain Not Configured
Set the business domain and retested with a fresh token: still 48001. This aligns with the principle—business domains, JS API security domains, and webpage authorization domains affect web-side capabilities (JSSDK, OAuth webpage authorization), while qrcode/create is a pure server-side API, unrelated to domain configuration.
Diagnostic Three-Step Method: Let WeChat Tell You the Answer
Much more efficient than guessing suspects one by one are two diagnostic APIs provided by WeChat:
① openapi/rid/get —— Query request details with the error rid
Every WeChat API error comes with an rid. Providing it to this API returns the complete context of that failed request:
curl "https://api.weixin.qq.com/cgi-bin/openapi/rid/get?access_token=$TK" \
-d '{"rid":"6a89846c-6a4ab866-02c7a905"}'
# returns invoke_time / cost_in_ms / request_url / response_body / client_ip
② openapi/quota/get —— Check if a specific API is open to this account
curl "https://api.weixin.qq.com/cgi-bin/openapi/quota/get?access_token=$TK" \
-d '{"cgi_path":"/cgi-bin/qrcode/create"}'
# Our return: {"errcode":76022,"errmsg":"could not use this cgi_path, no permission"}
76022 is the authoritative verdict: Regardless of what the backend page shows, this account has no permission for this API.
Ultimately, through rid we obtained the complete conclusion via WeChat's official support channel (original text):
This API is for creating parameterized QR code tickets. The caller account type is certified corporate subject official account. However, this API explicitly requires the calling account to be "authenticated non-individual service account", and 'official account' type accounts are not authorized to call it. Although the current account is certified and of corporate subject, due to account type mismatch, permission is denied. Official account and service account types cannot be converted to each other.
Key point: Correct certification status and subject type cannot save an incorrect account type. Subscription accounts (commonly referred to as "official accounts") and service accounts are two different species and cannot be converted—to use parameterized QR codes, you must register a service account from scratch.
Alternative Solution: Fixed QR Code + 6-Digit Verification Code
Not wanting to maintain a service account just for a login button. While reviewing the permission matrix, we noticed a key fact: sending and receiving text messages via server configuration requires no account type or certification threshold. So the solution changed to:
Webpage displays: Official Account fixed follow QR code + large 6-digit verification code
→ User scans to follow (or enters conversation if already following)
→ Sends this 6-digit number in the Official Account conversation box
→ Server receives text message, matches login ticket → confirms
→ Official Account immediately replies "✅ Login successful"
→ Webpage polls for confirmation, automatically enters login state within 2 seconds
Interaction changed from "scan to login" to "scan + send a code", adding one step but with zero cost, zero new qualifications, and the fan-growth property unchanged. A few implementation points:
Tickets Use Dual Identifiers, Separating Enumeration and Collision Prevention
scene: 32-bit random hex, only for frontend polling—the 6-digit number code has only one million combinations, direct polling would be enumerablecode: 6-digit number, only for users to send in WeChat, generated with deduplication check among active tickets, retried on collision
Ticket state machine is strictly unidirectional: pending → confirmed → (atomic updateMany consumption) → consumed, each ticket can only be exchanged for one session, and WeChat server event retries are naturally idempotent.
Manual Forging of NextAuth Database Session
This is the least common technique in the article. This login is not OAuth, Auth.js has no corresponding provider, and the Credentials provider doesn't support database session strategy. The solution is to bypass Auth.js and forge the session directly: query/create User + Account rows by openid, insert a Session row with the exact same structure as PrismaAdapter, then manually set the cookie:
const session = await prisma.session.create({
data: {
sessionToken: randomUUID(),
userId,
expires: new Date(Date.now() + 30 * 24 * 3600 * 1000), // consistent with auth config's maxAge
},
});
// In production (https), cookie name must be __Secure-authjs.session-token
res.cookies.set(cookieName, session.sessionToken, {
httpOnly: true, sameSite: "lax", secure: true, path: "/", maxAge: 30 * 24 * 3600,
});
After that, auth() / useSession() work completely unaware—we verified with assertions: requesting /api/auth/session with the forged cookie returns the user correctly via Auth.js.
Two Pitfalls in Multi-Machine Deployment
If your site, like ours, uses regional DNS to route to multiple servers:
- WeChat's event push only reaches domestic nodes (WeChat servers are in China, DNS resolves locally). Users might generate QR codes on overseas node pages—so ticket state must be in a shared database, not in process memory
- access_token is globally unique, repeated acquisition invalidates the old token and is daily-limited. Multiple machines refreshing independently kick each other—cache in a shared database single-row table, whoever's cache is about to expire refreshes and writes back
Don't Forget Passive Replies
After enabling server configuration, the Official Account backend's auto-reply becomes disabled (message processing is taken over by your server). The welcome message after following must be added in the passive reply of the subscribe event—also including the guide for "send verification code to login", forming a closed loop.
FAQ
Is 48001 Always an Account Type Issue?
Not necessarily. 48001 broadly means "API unauthorized", and could also be due to API freezing, permission set changes, etc. The judgment method remains unchanged: openapi/quota/get checks if that cgi_path is open to your account (76022 = not open), openapi/rid/get checks the specific request context, much faster than guessing from forums.
Why Does the Backend 'API Permission' Page Differ from Reality?
We didn't get an official explanation from WeChat side, but can confirm the phenomenon: page shows available, API actually returns 76022. The engineering conclusion is: base it on API returns, page display is for reference only.
Is the Verification Code Solution Secure?
Key design: polling credentials (32-bit random hex) and user-input 6-digit code are separate, enumeration surface doesn't exist on polling side; 6-digit code expires in 5 minutes, unique during active period, atomic consumption after confirmed prevents replay; event endpoint uses sha1 signature verification, forged callbacks return 403.
Do Subscription Accounts Really Not Need Certification to Send/Receive Messages?
Server configuration (URL + Token signature verification + plain/secure mode) can be enabled by all Official Accounts, and receiving and passive replying to text messages have no account type threshold. The threshold is for active capabilities: customer service message API, template messages, parameterized QR codes, etc., which do require account types.
Reference Links
- WeChat Official Documentation: Generating a Parametric QR Code
- WeChat Official Documentation: Receiving Standard Messages (Server Configuration)
- WeChat Official Documentation: OpenAPI Management Interface (rid Query / quota Query)
- Auth.js Database Session Strategy
- Concurrent Practice: Read-Write Separation Refactoring to Reduce Cross-Border Website Page from 2.7s to 0.8s