Back to Notes
Security2 phút đọcNovember 12, 2025

Auth Edge Cases trong Checkout — Những lỗi junior hay bỏ sót

Session hết hạn giữa lúc user điền form thanh toán, OAuth callback loop, giỏ hàng bị mất sau khi đăng nhập — những edge case này hiếm nhưng khi xảy ra sẽ gây mất đơn hàng và mất khách.

Auth Edge Cases trong Checkout — Những lỗi junior hay bỏ sót

Authentication là feature tưởng chừng đã có thư viện xử lý hết. Nhưng trong checkout flow, có những edge case mà auth library không thể tự giải quyết — và nếu bạn không xử lý, user sẽ mất đơn hàng.

Vấn đề 1: Session Expiry Mid-checkout

User mở tab checkout, đi pha cà phê 30 phút, quay lại điền xong form rồi submit — session hết hạn. Response trả về 401. Nếu không handle, toàn bộ dữ liệu form bị mất.

tsx
// middleware để detect session expiry và lưu form state trước khi redirect
async function submitCheckout(formData: CheckoutFormData) {
  try {
    const response = await fetch("/api/checkout", {
      method: "POST",
      body: JSON.stringify(formData),
    });
 
    if (response.status === 401) {
      // Lưu form data trước khi redirect login
      sessionStorage.setItem("checkout_recovery", JSON.stringify(formData));
      // Redirect về login với return URL
      window.location.href = `/login?returnTo=/checkout&reason=session_expired`;
      return;
    }
 
    const result = await response.json();
    router.push(`/orders/${result.orderId}/success`);
  } catch (error) {
    toast.error("Đã xảy ra lỗi. Vui lòng thử lại.");
  }
}
 
// Trong checkout page — recover form data sau login
useEffect(() => {
  const recovery = sessionStorage.getItem("checkout_recovery");
  if (recovery) {
    const savedData = JSON.parse(recovery);
    form.reset(savedData); // Restore form state
    sessionStorage.removeItem("checkout_recovery");
    toast.info("Đã khôi phục thông tin bạn vừa nhập.");
  }
}, []);

Vấn đề 2: OAuth Redirect Loop

Điều kiện xảy ra: callback URL bị encode sai, hoặc middleware redirect mọi request của unauthenticated user kể cả callback route.

ts
// proxy.ts / middleware.ts — PHẢI exclude auth callback routes
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
 
  // Các route này KHÔNG được bảo vệ bởi auth middleware
  const publicRoutes = [
    "/login",
    "/api/auth", // NextAuth callback route
    "/checkout/result", // Payment gateway redirect back
    "/_next",
    "/favicon.ico",
  ];
 
  const isPublic = publicRoutes.some((route) => pathname.startsWith(route));
  if (isPublic) return NextResponse.next();
 
  // Kiểm tra session
  const session = request.cookies.get("next-auth.session-token");
  if (!session && pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
 
  return NextResponse.next();
}

Vấn đề 3: Cart Persistence sau Login — Guest → Auth Merge

Đây là vấn đề đã đề cập ở bài cart state, nhưng từ góc độ auth flow:

ts
// auth/index.ts (NextAuth config)
export const authConfig = {
  callbacks: {
    async signIn({ user }) {
      // Trigger cart merge sau khi login thành công
      // Không merge ở component — merge ở server callback để đảm bảo chạy 1 lần
      try {
        await mergeGuestCartOnLogin(user.id!);
      } catch (error) {
        // Cart merge fail không nên block login
        console.error("Cart merge failed:", error);
      }
      return true;
    },
  },
};

Vấn đề 4: CSRF Protection trên Payment Form

tsx
// Luôn dùng Server Actions hoặc include CSRF token trong payment form
// Next.js Server Actions tự động có CSRF protection
 
// Nếu dùng API route thuần, phải tự implement:
async function getCSRFToken(): Promise<string> {
  const { token } = await fetch("/api/csrf").then((r) => r.json());
  return token;
}
 
// Form submission với CSRF token
async function handlePaymentSubmit(data: PaymentData) {
  const csrfToken = await getCSRFToken();
 
  await fetch("/api/payment", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-CSRF-Token": csrfToken,
    },
    body: JSON.stringify(data),
  });
}

Vấn đề 5: Remember Me Token — HttpOnly + Rotation

ts
// Server-side: set remember me cookie
export async function setRememberMeCookie(userId: string, res: Response) {
  const token = crypto.randomUUID(); // Secure random token
 
  // Lưu token vào DB với hashed value
  await db.insert(rememberMeTokens).values({
    userId,
    tokenHash: await hashToken(token), // Không lưu token gốc
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 ngày
  });
 
  // Set HttpOnly cookie — không accessible bởi JavaScript
  res.setHeader(
    "Set-Cookie",
    `remember_token=${token}; HttpOnly; Secure; SameSite=Strict; Max-Age=${30 * 24 * 3600}; Path=/`
  );
}
 
// Token rotation: mỗi lần dùng, invalidate token cũ và tạo token mới
export async function validateAndRotateToken(token: string) {
  const hashed = await hashToken(token);
  const record = await db.query.rememberMeTokens.findFirst({
    where: eq(rememberMeTokens.tokenHash, hashed),
  });
 
  if (!record || record.expiresAt < new Date()) return null;
 
  // Rotate: xóa token cũ, tạo token mới
  await db.delete(rememberMeTokens).where(eq(rememberMeTokens.id, record.id));
  await setRememberMeCookie(record.userId, res);
 
  return record.userId;
}

Key Takeaways

  • Session expiry: lưu form state trước khi redirect login, restore sau khi quay lại
  • OAuth loop: exclude /api/auth/* và payment callback routes khỏi auth middleware
  • Cart merge: thực hiện trong auth callback server-side, không ở component
  • CSRF: Server Actions tự bảo vệ; API routes thuần cần explicit CSRF token
  • Remember me: luôn HttpOnly, lưu hashed token, rotate mỗi lần dùng

If you found this helpful, leave a like!

Comments available after DB is connected.