Spring Boot QR Code Login: State Machines, SSE Push, and Replay Protection for Production
This article details a production-ready QR code login implementation using Spring Boot, covering state machine design with Redis and Lua for atomic transitions, SSE for low-latency status push with polling fallback, HMAC-SHA256 with nonce for replay protection, and Redis Pub/Sub for cross-instance consistency in clustered deployments.
Why QR Code Login Is Hard in Production
While the basic flow—PC shows QR code, phone scans, user confirms—seems simple, production deployments face four core challenges: how the PC learns instantly that the user scanned or confirmed, how to prevent ticket forgery and replay, how to keep state consistent across multiple service instances, and how to handle expiration, duplicate scans, network drops, and instance failures.
Overall Architecture
2.1 Roles
PC browser displays the QR code, subscribes to status updates, and exchanges a token for a login session. The mobile app scans, parses the ticket, and confirms. The login service generates tickets, maintains the state machine, and issues tokens. Redis stores ticket state, nonces, rate-limit counters, and serves as the broadcast backbone. The gateway handles rate limiting, blocklists, CORS, and TLS termination.
2.2 Ticket Structure
A ticket is the unique session identifier. Use a 32-character string from SecureRandom (or UUID). In Redis, store a Hash under key qr:ticket:{ticketId} with fields:
status: WAITING | SCANNED | CONFIRMED | USED | EXPIRED | CANCELED
clientId: PC device fingerprint
userId: confirmed user ID
scanToken: one-time token issued after scan
confirmToken: one-time token for PC to exchange for login session
createdAt: creation timestamp
expireAt: expiration timestamp
TTL: 120 seconds (configurable)2.3 State Machine
Allowed transitions only:
WAITING --scan--> SCANNED --confirm--> CONFIRMED --exchange--> USED
| |
+---- timeout/cancel --> EXPIRED / CANCELED WAITING: QR code just generated. SCANNED: Phone scanned, awaiting user confirmation. CONFIRMED: User confirmed, PC can exchange. USED: Login session issued, ticket invalidated. EXPIRED / CANCELED: Timeout or explicit cancel / risk-control intercept.
Two critical rules: all state changes must execute via Redis Lua scripts—never get then set in Java, or concurrent multi-instance requests will corrupt state. The CONFIRMED → USED step must validate confirmToken and succeed exactly once.
2.4 Sequence Diagram
sequenceDiagram
participant PC as PC Browser
participant Server as Login Service
participant Redis as Redis
participant Phone as Mobile App
PC->>Server: GET /qr/generate
Server->>Redis: SET qr:ticket:{id} status=WAITING EX 120
Server-->>PC: {ticketId, qrCodeUrl}
PC->>Server: GET /qr/subscribe?ticketId=xxx (SSE)
Phone->>Server: POST /qr/scan {ticketId, timestamp, nonce, sign}
Server->>Redis: Lua: WAITING -> SCANNED
Redis-->>Server: OK
Server-->>PC: SSE status=SCANNED
Phone->>Server: POST /qr/confirm {ticketId, userId, scanToken, timestamp, nonce, sign}
Server->>Redis: Lua: SCANNED -> CONFIRMED
Server-->>PC: SSE status=CONFIRMED
PC->>Server: POST /qr/exchange {ticketId, confirmToken, clientId}
Server->>Redis: Lua: CONFIRMED -> USED
Server-->>PC: {accessToken, refreshToken}QR Generation, Storage & One-Time Tokens
3.1 QR Code Generation with ZXing
Dependencies: com.google.zxing:core:3.5.3 and com.google.zxing:javase:3.5.3. Utility class sets UTF-8, error correction level H, margin 1, outputs PNG bytes. The QR content should be a signed URL, not just the ticketId:
https://app.example.com/scan?t={ticketId}&s={sign} sign= HMAC-SHA256(ticketId + clientId + timestamp, secret). The mobile app verifies the signature locally before calling the backend, preventing parameter tampering.
3.2 Redis Storage & Atomic Transitions
Lua script for state transition (keys[1] = ticket key, argv[1] = expected status, argv[2] = new status, argv[3] = userId, argv[4] = scanToken, argv[5] = ttlSeconds):
-- KEYS[1] = qr:ticket:{ticketId}
-- ARGV[1] = expectedStatus
-- ARGV[2] = newStatus
-- ARGV[3] = userId
-- ARGV[4] = scanToken
-- ARGV[5] = ttlSeconds
local status = redis.call('HGET', KEYS[1], 'status')
if status ~= ARGV[1] then
return 0
end
redis.call('HSET', KEYS[1], 'status', ARGV[2])
if ARGV[3] ~= '' then redis.call('HSET', KEYS[1], 'userId', ARGV[3]) end
if ARGV[4] ~= '' then redis.call('HSET', KEYS[1], 'scanToken', ARGV[4]) end
redis.call('EXPIRE', KEYS[1], ARGV[5])
return 1Java wrapper returns true only when Lua returns 1; 0 means state mismatch—caller decides whether to respond “already scanned” or “expired”.
3.3 Two One-Time Tokens
scanToken: issued after successful scan; required by /qr/confirm so only the scanning device can confirm. confirmToken: generated when state becomes CONFIRMED; PC presents it to /qr/exchange for login session. Exchange is also atomic via Lua:
local status = redis.call('HGET', KEYS[1], 'status')
local token = redis.call('HGET', KEYS[1], 'confirmToken')
if status ~= 'CONFIRMED' or token ~= ARGV[1] then
return 0
end
redis.call('HSET', KEYS[1], 'status', 'USED')
redis.call('EXPIRE', KEYS[1], 5)
return 1TTL strategy: 120 s in WAITING; after CONFIRMED shrink to 30 s to reduce exposure. Login session can be JWT + Redis blocklist or Spring Session + Redis.
How the PC Detects State Changes
Options: short polling, long polling, SSE, WebSocket.
Short polling: simple, universal compatibility, but latency vs. traffic trade-off; unsuitable as primary under load.
Long polling: better UX, but connection hold and timeout tuning are painful; not worth the ops cost.
SSE: unidirectional server push over HTTP, native browser auto-reconnect. Perfect fit—server pushes state, PC only receives. Spring MVC integration via SseEmitter.
WebSocket: full duplex, but requires protocol upgrade and extra client/server handling. Only justified if an existing WebSocket channel can piggyback the notifications.
Decision: primary = SSE; fallback = short polling. Frontend tries SSE first, falls back after repeated timeouts/errors.
Spring Boot controller sketch:
@RestController
@RequestMapping("/qr")
public class QrLoginController {
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
@GetMapping("/subscribe")
public SseEmitter subscribe(@RequestParam String ticketId) {
SseEmitter emitter = new SseEmitter(5 * 60_000L); // 5 min timeout
emitters.put(ticketId, emitter);
emitter.onCompletion(() -> emitters.remove(ticketId));
emitter.onTimeout(() -> emitters.remove(ticketId));
String status = qrTicketService.getStatus(ticketId);
try { emitter.send(SseEmitter.event().name("status").data(status)); }
catch (IOException e) { emitter.completeWithError(e); }
return emitter;
}
public void push(String ticketId, String status) {
SseEmitter emitter = emitters.get(ticketId);
if (emitter == null) return;
try {
emitter.send(SseEmitter.event().name("status").data(status));
if ("CONFIRMED".equals(status) || "EXPIRED".equals(status) || "CANCELED".equals(status)) {
emitter.complete();
emitters.remove(ticketId);
}
} catch (IOException e) {
emitter.completeWithError(e);
emitters.remove(ticketId);
}
}
}Note: emitters is single-instance; multi-instance requires the Pub/Sub layer (next section). On subscribe, push current state immediately to avoid missing a transition that occurred before the connection opened. The article recommends 5-minute SSE timeout (not 30 s) to avoid excessive reconnect heartbeats.
Security Hardening
5.1 Replay Protection
Every critical endpoint ( /qr/scan, /qr/confirm, /qr/exchange) requires timestamp, nonce, sign. timestamp: milliseconds; server rejects if |now - timestamp| > 5 minutes. nonce: random string; deduplicated via SETNX qr:nonce:{nonce} 1 EX 300. sign:
HMAC-SHA256(ticketId + userId + timestamp + nonce, appSecret).
Verification order matters: verify signature first, then consume nonce. The original draft did SETNX before signature check, which lets an attacker burn nonces with bogus signatures. Correct flow:
public boolean checkSign(String ticketId, String userId, long timestamp,
String nonce, String sign) {
if (Math.abs(System.currentTimeMillis() - timestamp) > 5 * 60_000) return false;
String data = ticketId + ":" + userId + ":" + timestamp + ":" + nonce;
String expected = HmacUtils.hmacSha256Hex(appSecret, data);
if (!MessageDigest.isEqual(expected.getBytes(), sign.getBytes())) return false;
Boolean first = redisTemplate.opsForValue()
.setIfAbsent("qr:nonce:" + nonce, "1", 5, TimeUnit.MINUTES);
return Boolean.TRUE.equals(first);
}5.2 Forgery Prevention
ticketIdfrom SecureRandom (≥32 chars). QR code URL signed by server; mobile app verifies locally, then calls backend. Server re-validates ticket existence and that status is WAITING.
5.3 Cross-Site Binding Prevention
At QR generation, store clientId (PC device fingerprint), UA hash, and Origin in the ticket. On /qr/exchange, verify clientId matches. Cookies use SameSite=Strict or Lax; gateway enforces Origin/Referer allowlist. Attack scenario: attacker hosts a phishing page with their own QR code; victim scans, logs attacker in. Binding exchange to the original clientId blocks this.
5.4 Confirmation Page Requirements
Before confirming, server checks: requester has valid login session, scanToken binds to this ticket, ticket not already confirmed. Confirmation page shows device, location, time; user explicitly approves. For sensitive actions (password change, payment) add fingerprint or SMS second factor.
5.5 IP & Device Risk Control
Rate-limit QR generation: 10/min per IP.
Multi-city confirmations in short window → geo-anomaly alert.
Emulators, rooted devices, abnormal fingerprints → raise risk level or deny.
Blocklists on IP, device ID, user ID → immediate reject.
Multi-Instance Deployment
6.1 State in Redis
Ticket state lives entirely in Redis; service is stateless. Use Redis Sentinel or Cluster—no single point.
6.2 Cross-Instance Broadcast
Problem: PC’s SSE connection lands on instance A; phone’s scan request hits instance B. B updates Redis but A doesn’t know. Solution: Redis Pub/Sub on channel qr:status:changed.
@Configuration
public class RedisPubSubConfig {
@Bean
RedisMessageListenerContainer container(RedisConnectionFactory factory,
QrStatusMessageListener listener) {
RedisMessageListenerContainer c = new RedisMessageListenerContainer();
c.setConnectionFactory(factory);
c.addMessageListener(listener, new ChannelTopic("qr:status:changed"));
return c;
}
}Message payload: { "ticketId": "abc", "status": "CONFIRMED" }. Each instance subscribes, looks up the ticketId in its local emitters map, pushes if present, otherwise discards. No retry logic here—Redis is the source of truth; PC re-fetches state on reconnect.
Caveat: Redis Pub/Sub does not guarantee delivery. Fallback: after SSE reconnect, PC immediately calls GET /qr/status to get current state instead of waiting for a push.
6.3 Session Consistency
Post-login session (JWT) also in Redis. Can use Spring Session + Redis for automatic cross-instance session sharing. Multi-device conflict: maintain user:online:{userId} set with device info; eviction policy decides whether to kick old device or prompt user.
6.4 Gateway Responsibilities
Rate-limit /qr/generate, /qr/scan, /qr/confirm by IP, user, and ticket. Dynamic blocklists via Redis or Nacos. Limit SSE connections per instance and per IP. Gateway timeout must exceed SSE timeout, otherwise the gateway cuts the connection early, causing mysterious client reconnect loops.
Exception Handling
Expiration : Redis TTL handles it; PC receives EXPIRED and prompts refresh.
Duplicate scan : if ticket already SCANNED or CONFIRMED, return “already scanned, do not repeat”; do not overwrite state.
User cancel : phone calls /qr/cancel; state → CANCELED (allowed from WAITING and SCANNED, not only SCANNED). PC shows canceled.
Multi-device conflict : inspect user:online:{userId}; apply business policy (kick old or ask).
Geo/new-device login : send in-app message, email, or SMS.
SSE disconnect : frontend auto-reconnects, then GET /qr/status to fetch current state, then re-subscribes.
Instance crash : SSE breaks, frontend falls back to short polling; Redis state persists.
Cancel endpoint example:
@PostMapping("/cancel")
public Result<Void> cancel(@RequestBody CancelRequest req) {
boolean ok = qrTicketService.transition(
req.getTicketId(), "SCANNED", "CANCELED", null, null, 30);
if (ok) {
redisTemplate.convertAndSend("qr:status:changed",
new QrStatusMessage(req.getTicketId(), "CANCELED"));
}
return Result.ok();
}API List, Load Testing & Go-Live
8.1 API Catalog
GET /qr/generate– params: clientId,
ua GET /qr/subscribe?ticketId=xxx– SSE GET /qr/status?ticketId=xxx – short-poll fallback POST /qr/scan – params: ticketId, userId, timestamp, nonce,
sign POST /qr/confirm– scan params +
scanToken POST /qr/cancel POST /qr/exchange– params: ticketId, confirmToken,
clientId /qr/scanand /qr/confirm enforce signature verification; /qr/exchange uses confirmToken + clientId dual check.
8.2 Load Test Targets
Single instance SSE connections ≥ 10,000.
QR generation QPS 5,000, P99 ≤ 200 ms.
Status query QPS 10,000, P99 ≤ 100 ms.
Redis memory: ~100 MB per 100k tickets; provision 2× headroom.
Gateway rate limits: start at 10/min per IP, 30/min per user; tune later.
Chaos drills: Redis failover, instance kill, SSE disconnect/reconnect—run all three.
8.3 Security Checklist
Full HTTPS; QR URL must be HTTPS. ticketId from secure RNG, length ≥ 32.
All critical endpoints validate timestamp + nonce + sign.
Nonce deduplication via Redis SETNX (TTL 5 min); verify signature before consuming nonce.
All ticket state changes via Lua. confirmToken single-use; immediate transition to USED.
Validate clientId, Origin, UA.
Confirmation page shows device, location, time; allows cancel.
Gateway rate limits, blocklists, CORS allowlist configured.
Log sanitization: no tokens, signatures, phone numbers in logs.
8.4 Pre-Launch
Redis HA, persistence, monitoring enabled.
SSE connection count metrics + timeout alerts.
QR TTL and confirmation timeout externalized as config.
Multi-instance Pub/Sub verified in staging, not just single-node.
Fallback (SSE → polling) actually triggers in test.
Penetration test: replay, CSRF, privilege escalation.
Canary release; watch login success rate, scan confirmation rate, error rate.
8.5 Closing Notes
The real difficulty of QR login isn’t the QR code itself—it’s the ticket state machine: guaranteeing atomic state transitions, security, and cross-instance consistency in a cluster. This design uses ZXing for QR generation, signed URLs for forgery protection, Redis Hash for state, Lua for atomicity, SSE for low-latency push with polling fallback, timestamp+nonce+HMAC for replay defense, and Redis Pub/Sub for cross-instance broadcast. It runs in several projects. Adjust TTL, signature algorithm, and risk rules to your security tier; load testing and security testing are ongoing, not one-off.
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.
Xiaolin Talks Programming
Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.
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.
