Magic Tools
Pitfall NotesBy CooconAugust 22, 2026147 views8 min read

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:

  1. Server calls cgi-bin/qrcode/create to generate a temporary QR code with scene parameter
  2. Display on the webpage, users scan to follow (or scan directly if already following)
  3. WeChat pushes subscribe / SCAN events to your configured callback URL, with openid and scene in the event
  4. 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 enumerable
  • code: 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:

  1. 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
  2. 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.

Get field notes like this every Saturday

Subscribe to Dev Breakfast: daily AI coding picks at 8:00, plus a Saturday roundup of this week's hands-on tests with Claude Code / Codex / local models. Written in Chinese.

Related Articles

Dev Breakfast · 2026-10-07

Today's headline: The same $200 Claude plan: what it's worth depends on which model you run. Plus 4 more: Vibe coding isn't as fun as hand-writing code: a viral HN post spells out 'frontloading the fun'; Mistral Large 4 opens preview: 1T params, 49B active, weights not out until month-end; and more.

daily-intelOct 7, 202611 min
37

Dev Breakfast · 2026-10-06

Today's headline: Wikipedia Looked Into OpenAI's Agent: Changed Configs, Probed for Holes, Crawled Millions of Pages. Plus 2 more: Opus 5.5 Found Two Room-Temperature Magnetic Semiconductors, But One Can't Be Synthesized; Beam Open-Sources 501B Weights: 23B Active, But the Weights Aren't Out Yet.

daily-intelOct 6, 20268 min
96

Dev Breakfast · 2026-10-05

Today's headline: macOS 27 kills the Apple Intelligence master switch: one script helps you evict the models. Plus 2 more: A 125B model running on a 12G GPU: up to 94 tokens/s in the official table; pstack ported to 6 coding Agents: Cursor's workflow has been carried over.

daily-intelOct 5, 20266 min
112

LM Studio Slow on a Mac? Tested: the 39-Second Wait Is Prompt Prefill, Not GPU Offload

LM Studio with gemma-4-e4b (Q4_K_M) on a 24GB M4 Mac mini: generation runs about 29.5 tok/s, and turning GPU offload from max to off costs only 11%. What really feels slow is prompt prefill — a 13K-token prompt waited 39.4 seconds for the first token (about 330 tokens/s), and 3.4–3.7x longer on CPU. A second request with the same prefix got its first token in 0.1 s thanks to prefix caching. With --parallel 4 and four simultaneous requests, each dropped to 9.7 tok/s.

local-llmmac-mini+5
pitfallsOct 5, 20267 min
84

Published by Magic Tools