Optional Auth and Guest Ordering: Designing for Zero Friction
Most apps today require you to create an account before you can do anything. I get why — it's easier to track users, build features around identity, and measure retention. But for a coffee shop ordering app, forced signup is a conversion killer. If I'm standing in a queue and your app wants my email, password, and date of birth before I can order a flat white, I'm just going to order at the till.
Dursley Donkey Coffee's ordering API treats user accounts as optional. You can place an order without logging in, get a number to listen for at the counter, and check your order status with no account at all. If you later decide to sign up, the app silently links your previous orders to your new account. Here's how that works and why it matters.
The Case Against Mandatory Signup
This is a coffee shop app. The core action — ordering a drink — should be as fast as possible. Every step between "I want a coffee" and "my order is placed" is a step where the user might give up and just walk to the counter instead.
Mandatory signup introduces at least three steps: enter email, create password, verify (or dismiss the verification prompt). That's three opportunities to lose the user before they've experienced any value from the app. And for what? So I can send them marketing emails they didn't ask for?
The alternative is simple: let them order first, experience the convenience, and then offer signup as an upgrade. Any user who creates an account after using the app as a guest has made an active choice. That's a much stronger signal of engagement than an account created under duress at the checkout screen.
How It Works in the API
The key is a middleware pattern I call optional auth. The order creation endpoint accepts a Bearer token if one is present, but doesn't require it:
optAuth := func(h http.HandlerFunc) http.Handler {
return optionalAuthMiddleware(db, cfg.JWTSecret, h)
}
mux.Handle("POST /api/orders", optAuth(handleCreateOrder(db)))
The optionalAuthMiddleware tries to parse the token. If it's valid, the user gets attached to the request context. If there's no token or it's invalid, the request continues without a user — no error, no rejection:
func optionalAuthMiddleware(db *sql.DB, jwtSecret string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
if strings.HasPrefix(header, "Bearer ") {
tokenStr := strings.TrimPrefix(header, "Bearer ")
claims := &jwt.RegisteredClaims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) {
return []byte(jwtSecret), nil
})
if err == nil && token.Valid {
var user User
err = db.QueryRow(
"SELECT id, email, password_hash, name, role, created_at FROM users WHERE id = ?",
claims.Subject,
).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.Name, &user.Role, &user.CreatedAt)
if err == nil {
ctx := context.WithValue(r.Context(), userContextKey, &user)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
}
}
next.ServeHTTP(w, r)
})
}
The order handler then checks whether a user is present. If so, the order is linked to their account. If not, it's a guest order:
var userID *string
customerName := req.CustomerName
if user != nil {
userID = &user.ID
if customerName == "" {
customerName = user.Name
}
}
This is a nullable user_id column in the database. Guest orders have NULL for user_id.
Confirmation Codes
Every order — guest or authenticated — gets a confirmation code. My first version generated random hex strings like DDC-3C7886. That felt right from a technical standpoint: unique, collision-resistant, searchable. But it was wrong for the actual use case.
This is a coffee shop. Staff call out your number when your order is ready. Nobody wants to shout "DDC-3C7886" across a busy counter. What they want is "Number 42!"
So I replaced the random codes with simple cycling integers:
orderID, _ := result.LastInsertId()
confirmationCode := (orderID-1)%100 + 1
Orders get numbers 1 through 100, then it cycles back to 1. That's it. The number is derived from the order ID, so it's deterministic and requires no extra state. At a coffee shop doing maybe 100-200 orders a day, the numbers will cycle once or twice — and by the time number 42 comes around again, the first order 42 was collected hours ago.
I chose this over email-based order tracking because it requires zero infrastructure. No email service, no "check your inbox" step, no spam filters to fight. The customer gets their number immediately and listens for it at the counter.
Order Claiming
The bridge between guest usage and account creation is the claim endpoint. My first version required the user to manually enter an order ID and confirmation code to claim each order one at a time. That worked, but it put the burden on the user — and if the whole point is zero friction, asking someone to dig up old order codes is the wrong move.
The reworked version is invisible to the user. The iOS app caches order IDs locally as guest orders are placed. When the user eventually signs up or logs in, the app sends all cached IDs in a single bulk request:
func handleClaimOrder(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r.Context())
var req ClaimOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorResponse(w, http.StatusBadRequest, "invalid request body")
return
}
if len(req.OrderIDs) == 0 {
errorResponse(w, http.StatusBadRequest, "order_ids is required and must not be empty")
return
}
claimed := []OrderDetailResponse{}
for _, orderID := range req.OrderIDs {
result, err := db.Exec(
"UPDATE orders SET user_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id IS NULL",
user.ID, orderID,
)
if err != nil {
continue
}
rows, _ := result.RowsAffected()
if rows == 0 {
continue
}
order := scanOrder(db, orderID)
if order != nil {
items := fetchOrderItems(db, order.ID)
claimed = append(claimed, OrderDetailResponse{Order: *order, Items: items})
}
}
jsonResponse(w, http.StatusOK, claimed)
}
}
The WHERE user_id IS NULL clause does the heavy lifting. It means an order can only be claimed if it's unclaimed — no confirmation code needed, no conflict errors, no user-facing validation. If an order ID in the batch is already claimed or doesn't exist, it's silently skipped. The response returns only the orders that were successfully linked.
This is where the business value of optional auth becomes concrete. A guest user's order history doesn't disappear when they create an account. The transition from anonymous to identified is seamless — the user signs up, the app fires off the claim request in the background, and their order history is there waiting for them. No manual steps, no codes to remember.
The Middleware Stack
The auth model ends up as four distinct levels, each composed from the same building blocks:
// Public — no auth
mux.HandleFunc("GET /api/menu", handleListMenu(db))
// Public with optional auth — works either way
mux.Handle("POST /api/orders", optAuth(handleCreateOrder(db)))
// Authenticated — any role
mux.Handle("GET /api/orders", auth(handleListOrders(db)))
// Staff only — auth + role check
mux.Handle("PATCH /api/orders/{id}/status", staff(handleUpdateOrderStatus(db)))
Each level is a thin wrapper. auth requires a valid token. staff wraps auth and adds a role check. optAuth tries to authenticate but doesn't fail if it can't. The composability is the point — adding a new access level is just a new combination of existing middleware.
Better Metrics
There's a less obvious benefit to optional auth that I think about a lot. When every user is forced to create an account, your "registered users" metric is inflated. It includes people who signed up because they had to, used the app once, and never came back. You can't distinguish genuine interest from grudging compliance.
With optional auth, any user who creates an account has actively chosen to. They've already used the app, seen value in it, and decided the benefits of an account (order history, loyalty features, faster checkout) are worth the signup. That's a much more meaningful metric. Your registered user count is smaller, but it's honest — every account represents a genuinely engaged user.
Trade-offs
Guest ordering adds complexity. The user_id column is nullable, which means every query that joins on users needs to handle the null case. Order listing for staff shows a mix of named accounts and anonymous orders. The claiming system is an extra endpoint and an extra flow to test.
Unclaimed guest orders aren't orphaned data — they're still valid business records with revenue, items sold, and timestamps. They're just less useful for user-level analysis. The shop owner still needs them for reporting and tax purposes regardless of whether a user account is attached.
The bulk claiming approach trusts the client — the app sends order IDs it cached locally, and the backend links them without further verification. This is acceptable for a coffee shop where the worst case is someone claiming an order that wasn't theirs, which has no meaningful consequence. For a system handling sensitive data, you'd want stronger proof of ownership. The security model matches the threat model.
Lessons Learned
Let users experience value before asking for commitment. The best time to ask for a signup is after the user has already benefited from your product, not before. A coffee order placed in 10 seconds is a better pitch for your app than any onboarding screen.
Optional auth is a product decision expressed in middleware. The technical implementation is straightforward — nullable user IDs, a middleware that doesn't fail on missing tokens, a claiming mechanism. The hard part is deciding to do it in the first place, because it means rethinking how every feature handles anonymous users.
Honest metrics are more useful than big numbers. A smaller count of genuinely engaged users tells you more about your product than a large count of forced signups. When you remove the pressure to create an account, the accounts that do get created mean something.