
小程序静默登录:从原理到 TypeScript 与 Go 实战
一、什么是静默登录
静默登录(Silent Login)是指用户打开小程序后,无需手动输入账号密码、无需点击任何登录按钮,系统自动完成身份认证并进入已登录状态的过程。
微信小程序生态中,静默登录依赖微信提供的 wx.login() 接口——它可以静默获取一个临时 code,后端通过这个 code 向微信服务器换取用户的 openid(用户唯一标识)和 session_key(会话密钥),从而建立用户身份。
为什么需要静默登录?
- 用户体验:小程序即开即用,首屏展示个性化内容,而不是登录页
- 转化率:每多一步操作就多一层流失,静默登录去掉了注册/登录的门槛
- 合规与便捷:无需用户手动输入手机号/密码,利用微信已有身份即可
静默登录 vs 传统登录
| 特性 | 传统登录 | 静默登录 |
|---|---|---|
| 用户操作 | 输入账号密码 / 短信验证码 | 无操作 |
| 首屏速度 | 需等待用户输入 | 进入即完成 |
| 依赖 | 用户记忆 / 手机号 | 微信生态能力 |
| 隐私范围 | 可获取手机号等敏感信息 | 仅获取 openid(匿名标识) |
| 适用场景 | 需绑定手机号的业务(支付、实名) | 内容浏览、工具类、电商浏览 |
二、静默登录的核心流程
┌───────────┐ ┌──────────────┐ ┌──────────────┐
│ 小程序端 │ │ 后端服务 │ │ 微信服务器 │
│ (TypeScript)│ │ (Go) │ │ (WeChat) │
└─────┬─────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ 1. wx.login() │ │
│──────────────────────→│ │
│ 返回 code │ │
│←──────────────────────│ │
│ │ │
│ 2. 发送 code 到后端 │ │
│──────────────────────→│ │
│ │ 3. code2session │
│ │────────────────────────→│
│ │←────────────────────────│
│ │ 返回 openid + session_key│
│ │ │
│ │ 4. 生成自定义登录态 Token │
│ │ (JWT) │
│ │ │
│ 5. 返回 token │ │
│←──────────────────────│ │
│ │ │
│ 6. 存储 token │ │
│ 到 Storage │ │
│ │ │
│ 7. 后续请求携带 token │ │
│═══════════════════════→│ │
│ │ 8. 验证 token │
│ │ 返回用户数据 │
│←═══════════════════════│ │
流程详解
- 调用 wx.login():小程序端调用
wx.login()获取临时 code(有效期 5 分钟,一次性) - code 发送到后端:将 code 通过 HTTPS 请求发送到自己的后端服务
- code2session:后端使用 appid + secret + code 向微信服务器换取
openid和session_key - 生成 Token:后端用 openid 生成自定义登录态 Token(JWT),可选地存储
session_key用于数据解密 - 返回 Token:后端将 Token 返回给小程序端
- 本地存储:小程序端将 Token 存入
wx.setStorageSync()或内存 - 携带 Token 请求:后续所有 API 请求在 HTTP Header 中携带 Token
- 验证 Token:后端验证 Token 有效性,返回对应业务数据
关键点:session_key 不应返回给前端,它只在后端使用(用于解密用户信息、获取手机号等敏感操作)。
三、前端实现(TypeScript)
3.1 封装静默登录模块
创建一个独立的登录服务模块,统一管理登录态:
// services/auth.ts
const TOKEN_KEY = 'access_token'
const EXPIRES_KEY = 'token_expires_at'
interface LoginResponse {
token: string
expires_in: number
user?: {
openid?: string
nickname?: string
avatar_url?: string
}
}
export async function silentLogin(): Promise<LoginResponse> {
const { code } = await wx.login()
if (!code) {
throw new Error('获取微信 code 失败')
}
const res = await wx.request<LoginResponse>({
url: 'https://api.example.com/v1/auth/miniapp-login',
method: 'POST',
data: { code },
})
if (res.statusCode !== 200 || !res.data.token) {
throw new Error('登录失败: ' + (res.data as any)?.message || '未知错误')
}
saveToken(res.data.token, res.data.expires_in)
return res.data
}
function saveToken(token: string, expiresIn: number): void {
const expiresAt = Date.now() + expiresIn * 1000
wx.setStorageSync(TOKEN_KEY, token)
wx.setStorageSync(EXPIRES_KEY, expiresAt)
}
export function getToken(): string | null {
const token = wx.getStorageSync(TOKEN_KEY)
const expiresAt = wx.getStorageSync(EXPIRES_KEY)
if (!token || !expiresAt) return null
if (Date.now() > expiresAt - 5 * 60 * 1000) {
removeToken()
return null
}
return token
}
export function removeToken(): void {
wx.removeStorageSync(TOKEN_KEY)
wx.removeStorageSync(EXPIRES_KEY)
}
export function isLoggedIn(): boolean {
return getToken() !== null
}
3.2 封装请求拦截器
每次请求自动携带 Token,并在 Token 失效时自动静默续期:
// services/http.ts
import { getToken, silentLogin, removeToken } from './auth'
const BASE_URL = 'https://api.example.com/v1'
let isRefreshing = false
let refreshQueue: Array<(token: string) => void> = []
export async function request<T = any>(
url: string,
options: WechatMiniprogram.RequestOption = {},
): Promise<T> {
const token = getToken()
const mergedOptions: WechatMiniprogram.RequestOption = {
...options,
url: url.startsWith('http') ? url : `${BASE_URL}${url}`,
header: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.header,
},
}
try {
const res = await wx.request<T>(mergedOptions)
if (res.statusCode === 401) {
return handleTokenRefresh(url, options)
}
return res.data
} catch (err) {
throw err
}
}
async function handleTokenRefresh<T>(
originalUrl: string,
originalOptions: WechatMiniprogram.RequestOption,
): Promise<T> {
if (!isRefreshing) {
isRefreshing = true
try {
const loginRes = await silentLogin()
const newToken = loginRes.token
isRefreshing = false
refreshQueue.forEach(cb => cb(newToken))
refreshQueue = []
return request<T>(originalUrl, originalOptions)
} catch (err) {
isRefreshing = false
refreshQueue = []
removeToken()
throw new Error('登录态已失效,请重新打开小程序')
}
}
return new Promise<T>((resolve, reject) => {
refreshQueue.push((newToken: string) => {
const retryOptions: WechatMiniprogram.RequestOption = {
...originalOptions,
header: {
...originalOptions.header,
Authorization: `Bearer ${newToken}`,
},
}
request<T>(originalUrl, retryOptions).then(resolve).catch(reject)
})
})
}
3.3 在 App 入口初始化
// app.ts
import { silentLogin, isLoggedIn } from './services/auth'
import { request } from './services/http'
App({
globalData: {
userInfo: null as any,
},
async onLaunch() {
await this.initAuth()
await this.loadUserProfile()
},
async initAuth() {
try {
if (!isLoggedIn()) {
const res = await silentLogin()
console.log('静默登录成功', res.user)
} else {
console.log('已有有效登录态')
}
} catch (err) {
console.warn('静默登录失败:', err)
}
},
async loadUserProfile() {
try {
const user = await request('/user/profile')
this.globalData.userInfo = user
} catch {
// 非关键数据,静默失败
}
},
})
3.4 需要用户授权的场景:获取手机号
部分业务需要用户手机号(如电商下单),此时无法静默完成,需要用户主动点击授权按钮:
// pages/login/index.ts
interface PhoneNumberEvent {
detail: {
code: string
encryptedData: string
iv: string
errMsg: string
}
}
Page({
async onGetPhoneNumber(e: PhoneNumberEvent) {
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
wx.showToast({ title: '需要授权手机号', icon: 'none' })
return
}
wx.showLoading({ title: '绑定中...' })
try {
const res = await request('/user/bind-phone', {
method: 'POST',
data: {
code: e.detail.code,
encryptedData: e.detail.encryptedData,
iv: e.detail.iv,
},
})
wx.showToast({ title: '绑定成功' })
} catch (err) {
wx.showToast({ title: '绑定失败', icon: 'error' })
} finally {
wx.hideLoading()
}
},
})
<!-- pages/login/index.wxml -->
<view class="container">
<text class="title">绑定手机号</text>
<text class="desc">绑定手机号后可使用全部功能</text>
<button
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumber"
class="phone-btn"
>
获取手机号
</button>
</view>
四、后端实现(Go)
4.1 项目结构
miniapp-backend/
├── main.go
├── handler/
│ └── auth.go
├── middleware/
│ └── auth.go
├── model/
│ └── user.go
├── service/
│ └── wechat.go
└── config/
└── config.go
4.2 配置管理
// config/config.go
package config
import "os"
type Config struct {
AppID string
AppSecret string
JWTSecret string
Port string
}
func Load() *Config {
return &Config{
AppID: os.Getenv("WECHAT_APPID"),
AppSecret: os.Getenv("WECHAT_APPSECRET"),
JWTSecret: os.Getenv("JWT_SECRET"),
Port: os.Getenv("PORT"),
}
}
func (c *Config) GetPort() string {
if c.Port == "" {
return ":8080"
}
return ":" + c.Port
}
4.3 微信 API 交互
// service/wechat.go
package service
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
)
type Code2SessionResponse struct {
OpenID string `json:"openid"`
SessionKey string `json:"session_key"`
UnionID string `json:"unionid,omitempty"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
type WeChatService struct {
appID string
appSecret string
client *http.Client
}
func NewWeChatService(appID, appSecret string) *WeChatService {
return &WeChatService{
appID: appID,
appSecret: appSecret,
client: &http.Client{},
}
}
func (s *WeChatService) Code2Session(code string) (*Code2SessionResponse, error) {
apiURL := fmt.Sprintf(
"https://api.weixin.qq.com/sns/jscode2session?"+
"appid=%s&secret=%s&js_code=%s&grant_type=authorization_code",
url.QueryEscape(s.appID),
url.QueryEscape(s.appSecret),
url.QueryEscape(code),
)
resp, err := s.client.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("请求微信服务器失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
var result Code2SessionResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
if result.ErrCode != 0 {
return nil, fmt.Errorf("微信接口错误 [%d]: %s", result.ErrCode, result.ErrMsg)
}
return &result, nil
}
4.4 JWT Token 生成与验证
// service/jwt.go
package service
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
type UserClaims struct {
OpenID string `json:"openid"`
UnionID string `json:"unionid,omitempty"`
jwt.RegisteredClaims
}
type JWTService struct {
secret []byte
issuer string
expiresIn time.Duration
}
func NewJWTService(secret string) *JWTService {
return &JWTService{
secret: []byte(secret),
issuer: "miniapp-auth",
expiresIn: 7 * 24 * time.Hour,
}
}
func (s *JWTService) GenerateToken(openID, unionID string) (string, error) {
now := time.Now()
claims := UserClaims{
OpenID: openID,
UnionID: unionID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(s.expiresIn)),
IssuedAt: jwt.NewNumericDate(now),
Issuer: s.issuer,
Subject: openID,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.secret)
}
func (s *JWTService) ParseToken(tokenString string) (*UserClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &UserClaims{},
func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v",
token.Header["alg"])
}
return s.secret, nil
})
if err != nil {
return nil, fmt.Errorf("解析 token 失败: %w", err)
}
claims, ok := token.Claims.(*UserClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("无效 token")
}
return claims, nil
}
4.5 用户模型
// model/user.go
package model
import "time"
type User struct {
ID int64 `json:"id" db:"id"`
OpenID string `json:"openid" db:"openid"`
UnionID string `json:"unionid,omitempty" db:"unionid"`
Nickname string `json:"nickname,omitempty" db:"nickname"`
AvatarURL string `json:"avatar_url,omitempty" db:"avatar_url"`
Phone string `json:"phone,omitempty" db:"phone"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type UserRepository interface {
FindByOpenID(openID string) (*User, error)
Create(user *User) error
Update(user *User) error
FindOrCreate(openID string) (*User, error)
}
4.6 登录处理器
// handler/auth.go
package handler
import (
"encoding/json"
"net/http"
)
type AuthHandler struct {
weChatService *WeChatService
jwtService *JWTService
userRepo UserRepository
}
func NewAuthHandler(
weChatService *WeChatService,
jwtService *JWTService,
userRepo UserRepository,
) *AuthHandler {
return &AuthHandler{
weChatService: weChatService,
jwtService: jwtService,
userRepo: userRepo,
}
}
type LoginRequest struct {
Code string `json:"code"`
}
type LoginResponse struct {
Token string `json:"token"`
ExpiresIn int64 `json:"expires_in"`
}
func (h *AuthHandler) MiniAppLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"message": "请求参数错误",
})
return
}
if req.Code == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{
"message": "缺少 code 参数",
})
return
}
session, err := h.weChatService.Code2Session(req.Code)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"message": "登录失败: " + err.Error(),
})
return
}
user, err := h.userRepo.FindOrCreate(session.OpenID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"message": "用户处理失败",
})
return
}
token, err := h.jwtService.GenerateToken(user.OpenID, session.UnionID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"message": "Token 生成失败",
})
return
}
writeJSON(w, http.StatusOK, LoginResponse{
Token: token,
ExpiresIn: 7 * 24 * 3600,
})
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
4.7 JWT 认证中间件
// middleware/auth.go
package middleware
import (
"context"
"net/http"
"strings"
)
type contextKey string
const UserClaimsKey contextKey = "user_claims"
type JWTParseFunc func(token string) (*UserClaims, error)
func AuthMiddleware(parseToken JWTParseFunc) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "缺少认证信息", http.StatusUnauthorized)
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, "认证格式错误", http.StatusUnauthorized)
return
}
claims, err := parseToken(parts[1])
if err != nil {
http.Error(w, "Token 无效或已过期", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), UserClaimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
4.8 主入口
// main.go
package main
import (
"log"
"net/http"
"your-project/config"
"your-project/handler"
"your-project/middleware"
"your-project/model"
"your-project/service"
)
func main() {
cfg := config.Load()
weChatSvc := service.NewWeChatService(cfg.AppID, cfg.AppSecret)
jwtSvc := service.NewJWTService(cfg.JWTSecret)
userRepo := model.NewMemoryUserRepo()
authHandler := handler.NewAuthHandler(weChatSvc, jwtSvc, userRepo)
mux := http.NewServeMux()
mux.HandleFunc("/v1/auth/miniapp-login", authHandler.MiniAppLogin)
mux.HandleFunc("/v1/user/profile", func(w http.ResponseWriter, r *http.Request) {
claims := middleware.GetUserClaims(r.Context())
user, _ := userRepo.FindByOpenID(claims.OpenID)
handler.WriteJSON(w, http.StatusOK, user)
})
mux.HandleFunc("/v1/user/bind-phone", authHandler.BindPhone)
protected := middleware.AuthMiddleware(jwtSvc.ParseToken)(mux)
log.Printf("服务启动于 %s", cfg.GetPort())
http.ListenAndServe(cfg.GetPort(), protected)
}
五、安全实践与常见问题
5.1 安全最佳实践
| 实践 | 说明 |
|---|---|
| HTTPS 强制 | 所有 API 必须使用 HTTPS,防止中间人攻击 |
| session_key 不返回前端 | 只保存在后端,用于解密敏感数据 |
| code 一次性使用 | 每个 code 只能使用一次,重放攻击无效 |
| Token 有效期 | 建议 7-30 天,与小程序生命周期匹配 |
| JWT 签名算法 | 生产环境使用 RS256/ES256 非对称签名 |
| 黑名单机制 | 维护 token 黑名单或版本号,支持主动失效 |
| 请求签名 | 重要接口增加请求签名验证 |
| 频率限制 | 登录接口做 IP/用户粒度的频率限制 |
5.2 Token 黑名单实现
// service/blacklist.go
package service
import (
"sync"
"time"
)
type TokenBlacklist struct {
mu sync.RWMutex
store map[string]time.Time
}
func NewTokenBlacklist() *TokenBlacklist {
b := &TokenBlacklist{
store: make(map[string]time.Time),
}
go b.cleanup()
return b
}
func (b *TokenBlacklist) Add(jti string, expiresAt time.Time) {
b.mu.Lock()
defer b.mu.Unlock()
b.store[jti] = expiresAt
}
func (b *TokenBlacklist) Exists(jti string) bool {
b.mu.RLock()
defer b.mu.RUnlock()
_, ok := b.store[jti]
return ok
}
func (b *TokenBlacklist) cleanup() {
ticker := time.NewTicker(10 * time.Minute)
for range ticker.C {
b.mu.Lock()
now := time.Now()
for jti, exp := range b.store {
if now.After(exp) {
delete(b.store, jti)
}
}
b.mu.Unlock()
}
}
5.3 常见问题
Q: wx.login() 返回的 code 有效期多长?
A: code 有效期为 5 分钟,且只能使用一次。使用后立即失效。
Q: session_key 会变化吗?
A: session_key 会在以下情况变化:用户调用 wx.login() 获取新 code 时、用户清除微信缓存、用户重新授权、用户更换设备登录。因此 session_key 不应作为长期存储凭据,每次 wx.login() 都需要重新获取。
Q: 静默登录和获取手机号冲突吗?
A: 不冲突。静默登录获取 openid(匿名身份标识),手机号授权获取用户手机号(实名信息)。先静默登录建立用户会话,在需要手机号时引导用户点击授权按钮。
Q: Token 过期后怎么办?
A: 前端的请求拦截器检测到 401 后,自动调用 wx.login() 重新静默登录,用户完全无感知。
Q: 多个小程序用同一个后端,如何区分用户?
A: 不同小程序的 appid 不同,openid 也不同。UnionID 是同一微信开放平台下多个应用的共享标识,可用于用户统一。
六、总结
小程序静默登录的核心思路可以概括为三点:
- 利用微信的登录能力:通过
wx.login()获取 code,后端用 code 换取 openid,无需用户输入任何凭证 - Token 替代 session:后端生成 JWT Token 作为登录态凭证,小程序端存储在本地,后续请求携带
- 自动续期:前后端配合实现 Token 过期自动刷新,用户全程无感知
前端 TypeScript 侧的核心是封装好 auth 和 http 模块,让业务代码无需关心登录态管理。后端 Go 侧的核心是处理好 code2session 的调用、JWT 的生成与验证,以及 session_key 的安全管理。
静默登录不是万能的——当业务需要手机号、实名认证等敏感信息时,仍需要用户主动授权。但它能很好地解决"首屏体验"问题,让用户打开小程序就能看到内容,而不是被挡在登录页后面。
最佳实践建议:先静默登录 → 后端记录匿名用户 → 用户在关键操作时(如下单、评论)引导绑定手机号。这样既保证了体验,又满足了业务合规。



