While working on Waitless, I needed customers to be able to track their position in a queue and leave the queue without creating an account.
That led to a small but interesting security design question:
How can I authorize an anonymous user without introducing a full authentication system?
My current solution is deliberately simple: each queue entry receives a random UUID token, and that token is used as a capability for the tracking link.
UUID token instead of a sequential ID
A sequential identifier could look like:
/track/123
The problem is that IDs such as 123, 124, and 125 are predictable and enumerable.
For an anonymous tracking system, I don't want a user to be able to guess another customer's resource simply by changing the ID.
Instead, an entry has a separate token:
model Entry {
id String @id @default(uuid())
token String @unique @default(uuid())
name String
email String?
position Int
status EntryStatus @default(WAITING)
queueId String
// ...
}
The public URL therefore looks more like:
/track/550e8400-e29b-41d4-a716-446655440000
The important point is that the token is not intended to be an ordinary identifier.
It acts as a capability.
What is a capability URL?
A capability can be thought of as:
An unguessable secret that grants access to a specific resource.
In my case:
possess valid token
↓
access this queue entry
The capability is deliberately designed to be difficult to guess.
That is different from simply hiding a sequential ID and hoping nobody discovers it.
This distinction matters:
Security by obscurity
→ "Nobody knows the ID."
Capability-based design
→ "Access requires possession of a deliberately unguessable credential."
The second is a much more intentional security model.
Why I kept the token simple for now
Waitless is an anonymous queueing system.
A customer does not need an account, password, or traditional login just to:
- see their position
- receive queue updates
- leave the queue
Introducing a complete authentication system would add considerable complexity to the product and to the user experience.
For the current use case, a capability link gives me a simple model:
Join queue
↓
Generate random token
↓
Give customer tracking link
↓
Customer presents token
↓
Server identifies the queue entry
↓
Allow limited actions
This is a conscious trade-off.
I'm choosing simplicity and frictionless anonymous access because the current resource is relatively limited in scope.
But the token is also a bearer credential
There is an important consequence.
If possession of the token grants access, then:
whoever possesses the token can use it.
That makes the token a bearer credential.
For example, the current leave() operation relies on the same tracking capability.
So if the token is leaked, copied, logged, or shared, another person could potentially use it.
That means the design is intentionally simple, but it is not the strongest possible security model.
Known limitation
The tracking token currently also functions as a bearer credential for
leave(), with no rate-limiting or one-time consumption.
I consider this an accepted limitation of the current design rather than something I want to hide.
A stronger design: capability URL → session cookie
One way to harden the system would be to use the tracking link only as an initial capability.
For example:
/claim/<secret>
↓
validate capability
↓
create anonymous session
↓
HttpOnly + Secure cookie
↓
redirect to /track
After that, the token would no longer need to remain in the URL.
The browser would use the session cookie for future requests.
This would reduce some of the risks associated with repeatedly carrying a secret in a URL and would give me more control over session lifecycle, expiration and revocation.
But it introduces another trade-off.
The trade-off: security vs. convenience
The current capability link has an important usability property:
The link itself works across devices.
A customer can open it on their phone, copy it to another device, bookmark it, or reopen it later.
With a one-time capability-to-session exchange, that behavior becomes more complicated.
For example:
Phone
↓
claim token
↓
session created
Now the same one-time token may no longer be usable on another device.
Supporting multiple devices would therefore require additional identity/session handling, which increases complexity.
For a simple anonymous queue, I decided that this trade-off is not currently justified.
When would this design change?
The answer is: when the product starts having identity requirements.
Imagine Waitless later introduces a reservation system.
Now a customer might have:
Account
↓
Reservations
↓
Queue entries
↓
Payment history
↓
Cancellation rules
↓
Personal information
The security requirements become much stronger.
At that point, a random tracking capability may no longer be enough as the main authorization mechanism.
I would probably move toward:
User authentication
↓
Authenticated session
↓
Authorization
↓
Reservation / queue access
The capability token could still exist for specific use cases, but it would no longer be responsible for the entire identity model.
UUIDv4 vs. UUIDv7
This also made me think more carefully about what an identifier is supposed to do.
For database identifiers, UUIDv7 is attractive because it is time-ordered and therefore provides better index locality than completely random UUIDv4 values.
For a public capability, however, my primary requirement is different:
I care more about unpredictability than database ordering.
That's why I would keep the concepts separate:
Database ID
↓
UUIDv7 can be a good choice
Public capability token
↓
Cryptographically random token / UUIDv4
This is an example of a broader engineering principle:
The best identifier for the database is not necessarily the best credential for the client.
What about storing the token itself?
Another improvement would be to store only a hash of the capability:
raw token
↓
SHA-256
↓
tokenHash
Then the database contains the verifier rather than the original bearer secret.
The application can hash the presented token and compare the result.
This gives additional protection if the database contents are exposed.
Again, it adds some implementation complexity, so it is a hardening step rather than something I needed to introduce simply to make the current Waitless flow work.
My current decision
For the current version of Waitless:
Random capability token
↓
Tracking URL
↓
Anonymous access
↓
Limited actions
I deliberately chose this because it provides a very small and understandable authorization mechanism without forcing customers through registration and login.
The trade-off is that the token is a bearer credential, so the system needs to treat it as sensitive.
I have therefore documented the limitation rather than pretending the design is perfect.
My planned evolution is roughly:
Current
Random capability URL
↓
Simple anonymous access
Later
Rate limiting
+ expiry / revocation
+ stronger token handling
When identity becomes important
↓
Capability URL
↓
Session / authentication
↓
Fine-grained authorization
The interesting lesson for me was not simply “use UUIDs.”
It was learning to ask:
What is this value actually supposed to represent?
An identifier, a capability, and an authenticated identity are three different concepts.
Once that distinction is clear, the architecture becomes much easier to reason about.
Top comments (2)
Since email is optional and the link is the whole credential, the email field is where it can leak: a mistyped address sends a working leave() link to a stranger, and nobody notices because the customer still has the page open. Splitting the capability would help there, with a view-only token for anything sent by email and the leave() token kept on the device that joined. Showing the address back on the confirmation screen before sending also catches the typo while they're still standing in the queue.
Good catch "your turn now " email does send the full token, including leave access, to whatever address was typed in. A mistyped email is a genuine leak vector I hadn't considered.
Showing the email back for confirmation before submitting is a cheap, immediate win regardless
adding that now!