Spring Boot Reimplementation of WeChat‑Style QR Login: Only Four Core States
The article walks through building a secure QR‑code login flow with Spring Boot, explaining why the QR should contain only a short‑lived random identifier, how to model four login states in Redis, avoid embedding JWTs, use WebSocket for instant updates, and enforce strict one‑time exchange checks.
1. Do Not Put JWT Directly in QR Code
A common mistake is to embed a generated JWT in the QR URL, e.g. String token = jwtService.generateToken(userId); and then https://example.com/login?token=xxxxx. Anyone who captures the QR (screenshot, photo, etc.) can obtain the token and log in, so the QR must never contain authenticated credentials.
Instead, the QR should only carry a short‑lived, random, one‑time qrId, such as https://m.example.com/qr-login?qrId=K6Tu7Rby95z. This identifier merely represents a browser waiting for login and holds no user identity, JWT, session, or permissions.
There is a browser waiting for login; it has no user identity.
2. The Whole QR Login Has Only Four Core States
The Redis‑backed state machine is defined as:
public enum QrLoginStatus {
WAITING,
SCANNED,
CONFIRMED,
EXPIRED
}The flow is:
Web generates QR → WAITING → (user scans) → SCANNED → (user confirms) → CONFIRMED → Session / JWTIf no action occurs within two minutes, WAITING → EXPIRED. The SCANNED state cannot directly become a successful login because scanning and user consent are distinct actions.
3. Generating the QR Code in Java
Dependencies:
Java 21
Spring Boot 4.1.0
Redis
Spring WebSocket
ZXing 3.5.4Add ZXing:
com.google.zxing:core:3.5.4
com.google.zxing:javase:3.5.4Generate a truly random qrId:
public String randomToken() {
byte[] bytes = new byte[32];
SecureRandom random = new SecureRandom();
random.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
String qrId = randomToken();
String url = "https://m.example.com/qr-login?qrId=" + qrId;Encode the URL with ZXing:
BitMatrix matrix = new QRCodeWriter().encode(url, BarcodeFormat.QR_CODE, 300, 300);
BufferedImage image = MatrixToImageWriter.toBufferedImage(matrix);The QR image itself carries no login permission; it is only an entry point.
4. Adding a Browser Token
When the browser requests POST /api/qr-login/create, the server generates both qrId and a browserToken and returns them (along with TTL and QR image URL). The browserToken never appears in the QR image.
{
"qrId": "QR_xxxxx",
"browserToken": "BT_xxxxx",
"expiresIn": 120,
"qrCodeUrl": "/api/qr-login/QR_xxxxx/image"
}Redis stores the session:
{
"status": "WAITING",
"browserTokenHash": "...",
"userId": null,
"createdAt": "...",
"expiresAt": "..."
}5. Scanning the QR Code
Assuming the user is already logged into the mobile app (e.g., userId = 10086), the scan request is: POST /api/qr-login/{qrId}/scan Controller:
@PostMapping("/{qrId}/scan")
public void scan(@PathVariable String qrId, Authentication authentication) {
Long userId = loginUserId(authentication);
qrLoginService.scan(qrId, userId);
}Service logic:
if (session.status() != QrLoginStatus.WAITING) {
throw new IllegalStateException("二维码状态错误");
}
session.setUserId(userId);
session.setStatus(QrLoginStatus.SCANNED);
save(session);The browser UI changes from “请扫码” to “已扫码,请在手机确认”.
6. Real‑Time Notification with WebSocket
Instead of polling, a WebSocket endpoint /ws/qr-login/{qrId} is opened. When the mobile app scans, it sends {"type":"SCANNED"} through the socket, causing the browser to display the confirmation prompt instantly.
After the user clicks “确认登录”, the mobile app posts: POST /api/qr-login/{qrId}/confirm Server updates the state to CONFIRMED and pushes {"type":"CONFIRMED"} via WebSocket.
WebSocket pushes are still not JWTs; they only tell the browser that it may now exchange the login.
7. Exchanging for a Real Session
The browser, upon receiving CONFIRMED, calls: POST /api/qr-login/exchange Payload:
{
"qrId": "QR_xxxxx",
"browserToken": "BT_xxxxx"
}Backend checks four conditions:
1. QR still exists
2. Status is CONFIRMED
3. browserToken matches
4. QR has never been exchanged beforeIf all pass, it retrieves userId, creates a real session/JWT, and calls consumeQrLoginSession(qrId) to mark the QR as used.
The QR can be exchanged only once.
Atomic consumption can be implemented with Redis Lua scripts or other atomic operations to avoid race conditions.
8. Security Hardening Before Production
QR TTL of 1–2 minutes (e.g., 120 seconds).
Require explicit mobile confirmation; scanning alone does not log in.
Display device information (browser, OS, location, timestamp) on the confirmation page.
Never place sensitive credentials (JWT, refresh token, userId, permissions) inside the QR.
Destroy the entire temporary state (qrId, browserToken, userId, status) immediately after successful exchange.
9. Conclusion
Implementing QR‑code login reveals a compact yet comprehensive workflow involving random token generation, a Redis‑backed one‑time state machine, authentication, one‑time credential exchange, WebSocket for instant feedback, expiration handling, concurrency control, and strict security boundaries. Remember: the QR code is not a login credential; it is merely a short‑lived authorization identifier.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
LuTiao Programming
LuTiao Programming is a friendly community offering free programming lessons. We inspire learners to explore new ideas and technologies and quickly acquire job-ready skills.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
