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.

Xiaolin Talks Programming
Xiaolin Talks Programming
Xiaolin Talks Programming
Spring Boot QR Code Login: State Machines, SSE Push, and Replay Protection for Production

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 1

Java 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 1

TTL 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

ticketId

from 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/scan

and /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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

distributed systemsRedisstate machineSpring BootSecuritySSEQR Code LoginLua Scripts
Xiaolin Talks Programming
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.