Getting stuck on Mini Program login
Over the past couple of days, I’ve been wrestling with a WeChat Mini Program project, and there was one issue I couldn’t avoid: user login.
From what I found, there are currently two mainstream ways to handle login in a Mini Program:
- Login through authorized phone number access
- Login through user authorization
The first option consumes quota from the phone number authorization component and is a paid route. Since I already had my own server and database, that felt unnecessary. It made more sense to build my own backend API and let the Mini Program use that directly.
So I spent one long stretch digging through practically every article I could find on WeChat login, comparing approaches, testing code, and eventually piecing the whole thing together. After finally getting it working, I wanted to write down the full process and the things that mattered.
Once the backend was in place, including both API and database, it was able to cover these basics:
<table> <thead> <tr> <th>Item</th> <th>Status</th> <th>Notes</th> </tr> </thead> <tbody> <tr> <td>User isolation</td> <td>✅</td> <td>openid unique index + foreign key constraints</td>
</tr>
<tr>
<td>Duplicate registration prevention</td>
<td>✅</td>
<td>ON DUPLICATE KEY UPDATE</td>
</tr>
<tr>
<td>Token security</td>
<td>✅</td>
<td>Fixed 64-character secret + HMAC-SHA256</td>
</tr>
<tr>
<td>Persistent storage</td>
<td>✅</td>
<td>MySQL + prepared statements</td>
</tr>
<tr>
<td>Order isolation</td>
<td>✅</td>
<td>WHERE openid = ? automatic filtering</td>
</tr>
</tbody>
</table>
API endpoint example:
https://api.xxx.com/api.php
The login flow in plain terms
The overall flow looks like this:
The Mini Program calls wx.login() to get a code → the backend exchanges that code for openid + session_key → the backend generates a JWT token → the Mini Program stores the token and session_key → the user grants profile access and returns encryptedData + iv → the backend decrypts the authorized user info and saves it into MySQL → login is complete.
Before doing any of this, you need a few things ready:
- A server and domain name with HTTPS
- A Mini Program project
- A PHP + MySQL environment
Mini Program side
In App.js, I keep some shared global state and define a login method that handles wx.login() and sends the returned code to the backend.
globalData: {
customTabBar: null,
userInfo: null,
phone: null,
token: null,
baseUrl: 'https://api.xxx.com/api.php' // 你的后端接口地址
},
login(cb) {
wx.login({
success: (res) => {
if (res.code) {
wx.request({
url: `${this.globalData.baseUrl}?action=login`,
method: 'POST',
header: { 'content-type': 'application/x-www-form-urlencoded' },
data: { code: res.code },
success: (reqRes) => {
const { token, session_key, exists } = reqRes.data;
if (!token) {
wx.showToast({ title: '登录失败', icon: 'none' });
return;
}
this.globalData.token = token;
this.globalData.sessionKey = session_key;
wx.setStorageSync('token', token);
wx.setStorageSync('sessionKey', session_key); // 持久化存储
cb && cb();
}
});
}
}
});
},
The main job here is simple: get the temporary login credential from WeChat, hand it to the server, and cache the returned token and session_key locally for later use.
On the user login screen, authorization happens only after making sure the session_key still exists.
// 获取用户信息授权
onGetUserInfo(e) {
if (e.detail.errMsg !== 'getUserInfo:ok') return;
const { encryptedData, iv } = e.detail;
// 确保 session_key 存在且未过期
if (!app.globalData.sessionKey) {
// 尝试从缓存恢复
app.globalData.sessionKey = wx.getStorageSync('sessionKey');
}
if (!app.globalData.sessionKey) {
wx.showToast({ title: '登录已过期,请重启小程序', icon: 'none' });
// 重新登录
app.login(() => {
this.onGetUserInfo(e); // 重试
});
return;
}
wx.request({
url: `${app.globalData.baseUrl}?action=save_userinfo`,
method: 'POST',
header: {
'Authorization': `Bearer ${app.globalData.token}`,
'content-type': 'application/x-www-form-urlencoded'
},
data: {
encryptedData,
iv,
session_key: app.globalData.sessionKey
},
success: (res) => {
console.log('/save_userinfo 返回', res.data);
if (res.data.success) {
app.globalData.userInfo = res.data.userInfo;
this.updateUserInfo();
wx.showToast({ title: '授权成功', icon: 'success' });
} else if (res.data.error === '未授权或token过期') {
// Token 过期,重新登录
app.login(() => {
this.onGetUserInfo(e); // 重试
});
} else {
wx.showToast({ title: res.data.error || '保存失败', icon: 'none' });
}
},
fail: (err) => {
console.error('请求失败', err);
wx.showToast({ title: '网络错误', icon: 'none' });
}
});
},
This part matters because user profile data does not come back as plain JSON you can trust directly. What you get is encrypted data plus an IV, and the backend needs the session_key from the current login session to decrypt it.
Once authorization succeeds, the page refreshes its displayed avatar, nickname, and other local user state:
// 更新用户信息
updateUserInfo() {
const app = getApp();
// 用最新 globalData 覆盖 page.data
this.setData({
'userInfo.avatar': app.globalData.userInfo?.avatarUrl || '/assets/logo.png',
'userInfo.nickname': app.globalData.userInfo?.nickName || '点击登录',
'userInfo.phone': app.globalData.phone || ''
});
console.log('刷新头像昵称', this.data.userInfo);
},
// 点击用户信息区域
onUserInfoTap() {
if (!app.globalData.token) {
wx.showToast({
title: '请先登录',
icon: 'none'
});
return;
}
if (!app.globalData.userInfo) {
this.showAuthModal();
} else {
// 已登录,可以编辑信息
console.log('用户已登录,可以编辑信息');
}
},
And before showing the authorization button, a modal is displayed so the user knows why profile access is being requested:
// 显示授权弹窗
showAuthModal() {
wx.showModal({
title: '授权登录',
content: '需要获取您的微信头像和昵称',
success: (res) => {
if (res.confirm) {
// 用户同意,显示授权按钮
this.setData({ showAuthButton: true });
}
}
});
},
Backend: PHP API design
The backend is where the real identity binding happens. It accepts the code, exchanges it with WeChat, writes user records into MySQL, issues tokens, and later decrypts the user profile payload.
Here is the full PHP example:
<?php
// api.php
ini_set('display_errors', 1);
error_reporting(E_ALL);
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *'); // 生产环境改为小程序域名
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
// ==================== 配置区 ====================
define('WX_APPID', '你的小程序AppID');
define('WX_SECRET', '小程序后台生成');
define('JWT_SECRET', '自行生成');
// MySQL 配置
define('DB_HOST', 'localhost');
define('DB_NAME', '数据库名');
define('DB_USER', '数据库用户名');
define('DB_PASS', '数据库密码');
function get_db() {
static $pdo;
if (!$pdo) {
try {
$pdo = new PDO(
"mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4",
DB_USER,
DB_PASS,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false
]
);
} catch (PDOException $e) {
error_log("DB Connection Error: " . $e->getMessage());
exit(json_encode(['error' => '系统错误']));
}
}
return $pdo;
}
function get_post_data() {
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (strpos($contentType, 'application/json') !== false) {
return json_decode(file_get_contents('php://input'), true) ?: [];
}
return $_POST;
}
function generate_token($openid) {
$payload = ['openid' => $openid, 'iat' => time(), 'exp' => time() + 86400 * 7];
$header = base64_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$payload = base64_encode(json_encode($payload));
$signature = hash_hmac('sha256', "$header.$payload", JWT_SECRET);
return "$header.$payload.$signature";
}
function verify_token($token) {
$parts = explode('.', $token);
if (count($parts) !== 3) return null;
$signature = hash_hmac('sha256', "$parts[0].$parts[1]", JWT_SECRET);
if (!hash_equals($signature, $parts[2])) return null;
$payload = json_decode(base64_decode($parts[1]), true);
if (!$payload || $payload['exp'] < time()) return null;
return $payload['openid'];
}
function wx_decrypt($encryptedData, $iv, $sessionKey) {
$key = base64_decode($sessionKey);
$data = base64_decode($encryptedData);
$iv = base64_decode($iv);
if (!$key || !$data || !$iv) return null;
$decrypted = openssl_decrypt($data, 'AES-128-CBC', $key, OPENSSL_RAW_DATA, $iv);
return $decrypted ? json_decode($decrypted, true) : null;
}
function generate_order_no($pdo) {
$date = date('Ymd');
$pdo->beginTransaction();
try {
$stmt = $pdo->query("SELECT MAX(CAST(SUBSTRING_INDEX(order_no, '_', -1) AS UNSIGNED)) as max_seq
FROM orders WHERE DATE(created_at) = CURDATE() FOR UPDATE");
$nextSeq = ($stmt->fetchColumn() ?? 0) + 1;
$orderNo = "{$date}_{$nextSeq}";
$pdo->commit();
return $orderNo;
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}
}
$action = $_GET['action'] ?? '';
$input = get_post_data();
try {
switch ($action) {
case 'login': wechat_login($input); break;
case 'save_userinfo': wechat_save_userinfo($input); break;
case 'save_phone': wechat_save_phone($input); break;
case 'user_info': wechat_user_info(); break;
case 'create_order': wechat_create_order($input); break;
case 'get_orders': wechat_get_orders(); break;
default: echo json_encode(['error' => '未知接口']);
}
} catch (Exception $e) {
error_log("API Error: " . $e->getMessage());
echo json_encode(['error' => '系统繁忙,请稍后重试']);
}
// 登录/注册(自动处理重复登录)
function wechat_login($input) {
$code = $input['code'] ?? '';
if (!$code) exit(json_encode(['error' => '缺少code']));
$url = "https://api.weixin.qq.com/sns/jscode2session?appid=" . WX_APPID . "&secret=" . WX_SECRET . "&js_code={$code}&grant_type=authorization_code";
$res = file_get_contents($url);
$arr = json_decode($res, true);
if (isset($arr['errcode'])) {
exit(json_encode(['error' => '微信接口错误', 'msg' => $arr['errmsg']]));
}
$openid = $arr['openid'];
$session_key = $arr['session_key'];
$pdo = get_db();
// 核心:ON DUPLICATE KEY UPDATE 确保同一 openid 只存在一条记录
$stmt = $pdo->prepare("
INSERT INTO users (openid, session_key, updated_at)
VALUES (:openid, :session_key, NOW())
ON DUPLICATE KEY UPDATE session_key = VALUES(session_key), updated_at = NOW()
");
$stmt->execute([
':openid' => $openid,
':session_key' => $session_key
]);
$token = generate_token($openid);
// 检查是否已授权
$stmt = $pdo->prepare("SELECT nickname, points FROM users WHERE openid = ?");
$stmt->execute([$openid]);
$user = $stmt->fetch();
echo json_encode([
'token' => $token,
'session_key' => $session_key,
'exists' => !empty($user['nickname']),
'points' => $user['points'] ?? 0
]);
}
// 在 wx_decrypt 函数后添加 session_key 刷新机制
function refresh_session_key($openid, $code) {
// 用新 code 换取新 session_key
$url = "https://api.weixin.qq.com/sns/jscode2session?appid=" . WX_APPID . "&secret=" . WX_SECRET . "&js_code={$code}&grant_type=authorization_code";
$res = file_get_contents($url);
$arr = json_decode($res, true);
if (isset($arr['session_key'])) {
$pdo = get_db();
$stmt = $pdo->prepare("UPDATE users SET session_key = ? WHERE openid = ?");
$stmt->execute([$arr['session_key'], $openid]);
return $arr['session_key'];
}
return null;
}
// 保存用户信息
function wechat_save_userinfo($input) {
// 添加内部 try-catch,直接暴露错误
try {
$token = explode(' ', $_SERVER['HTTP_AUTHORIZATION'] ?? '')[1] ?? '';
$openid = verify_token($token);
if (!$openid) exit(json_encode(['error' => '未授权或token过期']));
$encryptedData = $input['encryptedData'] ?? '';
$iv = $input['iv'] ?? '';
$sessionKey = $input['session_key'] ?? '';
if (!$sessionKey) {
exit(json_encode(['error' => '缺少 session_key']));
}
$userInfo = wx_decrypt($encryptedData, $iv, $sessionKey);
if (!$userInfo) {
exit(json_encode(['error' => '解密失败']));
}
$pdo = get_db();
$stmt = $pdo->prepare("
UPDATE users
SET nickname = ?, avatar_url = ?, gender = ?, city = ?, province = ?, country = ?, updated_at = NOW()
WHERE openid = ?
");
$stmt->execute([
$userInfo['nickName'] ?? '微信用户',
$userInfo['avatarUrl'] ?? '',
$userInfo['gender'] ?? 0,
$userInfo['city'] ?? '',
$userInfo['province'] ?? '',
$userInfo['country'] ?? '',
$openid
]);
echo json_encode(['success' => true, 'userInfo' => $userInfo]);
} catch (Exception $e) {
// 直接返回真实错误,不隐藏
echo json_encode(['error' => 'DEBUG: ' . $e->getMessage()]);
exit;
}
}
// 保存手机号
function wechat_save_phone($input) {
//仅做展示功能
}
// 创建订单
function wechat_create_order($input) {
//仅做展示功能
}
// 获取用户订单列表
function wechat_get_orders() {
//仅做展示功能
}
A few implementation points that are easy to miss
openid is the real identity anchor
The backend does not identify users by nickname or avatar. Those can change. The stable key is openid, which is unique for each user in the Mini Program ecosystem.
That is why the database uses openid as a unique index, and why user-related queries can be isolated around it.
Duplicate registration is solved at the SQL layer
The most practical part of the login endpoint is this:
ON DUPLICATE KEY UPDATE
That single clause ensures that if the same user logs in again, the existing record is updated instead of creating a second one. In this case, the session_key and updated_at fields are refreshed automatically.
Token validation is separate from WeChat decryption
The JWT token is used by the backend to confirm that the requester is already logged in and tied to a specific openid. The session_key, meanwhile, is used for decrypting WeChat’s encrypted user profile payload.
These are two different roles:
token: proves identity to your backendsession_key: decrypts the data returned by WeChat
Session expiration needs to be handled carefully
The frontend code already checks whether session_key exists, and if not, it tries to restore it from local storage. If it still cannot find one, it forces a new login and retries the authorization request.
There is also a backend function for refreshing session_key with a new code, which is useful when you want a way to recover from expiration more gracefully.
What the whole process is actually doing
If you strip away the implementation details, the logic is fairly straightforward.
Step 1: get a temporary credential
The Mini Program calls wx.login(), and WeChat returns a one-time code. This code can only be used once and is valid for 5 minutes. It works like a temporary proof that the current user is trying to log in.
Step 2: exchange it for user identity
The backend immediately sends that code to WeChat’s server and gets back two key pieces of data:
openid: the user’s unique WeChat ID within the Mini Program, essentially a permanent digital identifiersession_key: a temporary encryption key for the current login session, valid for about 30 minutes
Step 3: issue an access token
The backend takes the openid, combines it with a fixed secret and an expiration time, and creates a JWT token. That token is what the Mini Program stores and attaches to later requests as proof that the user is logged in.
Step 4: request authorization and send encrypted data
When the user taps the authorization button, WeChat encrypts profile fields such as avatar and nickname using the current session_key, then returns them as encryptedData. The Mini Program sends that encrypted payload, together with the IV and the token, to the backend.
Step 5: decrypt and persist
The backend verifies that the token is still valid, uses the session_key to decrypt the profile data, and stores openid, nickname, avatar, and related fields in MySQL. On the next visit, the backend only needs to look up the openid to know who the user is.
Why this setup is secure enough for normal use
There are three basic layers of protection built into the design.
- Identity lock:
openidis globally unique for the Mini Program, and the database enforces uniqueness so one person maps to one record. - Transmission lock: profile data is encrypted with
session_key, so intercepted data cannot be read without the proper key. - Permission lock: backend queries always scope user data by
openid, for example through conditions likeWHERE openid = ?, which prevents one user from reading another user’s orders.
That last point is especially important once you go beyond login and start dealing with user-specific records such as orders, points, addresses, or account history.