The unguessable slug: building public-but-private URLs without OAuth
Proofly has two types of public URLs that don't require login — the customer recording page and the embeddable wall feed. Both are secured entirely by an unguessable random slug. Here's how we generate them, what makes them secure, and where the tradeoffs are.
TL;DR
A 10-character random slug from crypto.randomBytes over a 36-character alphabet gives 36^10 ≈ 3.7 trillion possible values. With rate limiting, that's unguessable in practice. The slug *is* the auth — no OAuth, no signed URL, no expiry. The tradeoff: revocation requires generating a new slug.
Proofly has two URLs that are public but not meant to be public in the sense of being indexed, linked, or browsable. The customer recording page (/record/[slug]) and the embeddable wall feed (/api/wall/[slug]) are accessible to anyone who knows the URL — but the URL itself is the access control.
This pattern is sometimes called "security through obscurity," which has a bad reputation for good reason. We'd push back on that label here. The security doesn't come from obscuring an algorithm — the slug generation algorithm is fully public in this article. It comes from the entropy of the value. The slug is not guessable, not derivable, and not enumerable.
How the slugs are generated#
Both the testimonial request slug and the wall embed slug are generated by the same function:
// lib/slug.ts
import { randomBytes } from "node:crypto";
const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
export function generateSlug(length = 10): string {
const bytes = randomBytes(length);
let out = "";
for (let i = 0; i < length; i++) {
out += ALPHABET[bytes[i] % ALPHABET.length];
}
return out;
}
Three things to note:
crypto.randomBytes — not Math.random(). Math.random() is a pseudorandom number generator seeded from a predictable source. An attacker who knows the timing of a slug generation could theoretically narrow the search space significantly. crypto.randomBytes uses the operating system's cryptographically secure random number generator, which is seeded from hardware entropy. The output is unpredictable regardless of when it's called.
The alphabet is 36 characters — lowercase letters plus digits. We deliberately excluded uppercase letters (to avoid ambiguity between 0/O and 1/l/I), special characters (to avoid URL encoding issues), and confusing homoglyphs. The slug will always be readable, typeable, and safe to embed in any URL without encoding.
Modulo bias — bytes[i] % 36 has a slight bias because 256 doesn't divide evenly into 36. The last few values in each 256-byte block are slightly over-represented. For a 10-character slug this bias is negligible (the bias is about 0.4% per character), but if you needed cryptographic-grade uniformity you'd use rejection sampling. For a URL slug, this level of bias doesn't affect security.
The entropy calculation#
A 10-character slug over a 36-character alphabet: 36^10 = 3,656,158,440,062,976 possible values. That's about 3.7 quadrillion.
In bits: log2(36^10) = 10 × log2(36) ≈ 51.7 bits of entropy.
To put that in context: if an attacker tried 1,000,000 guesses per second (which would require them to make 1M HTTP requests per second to your server — you'd notice), it would take an average of 3.7 billion seconds to find a valid slug by brute force. That's over 117 years.
In practice, the rate limiting on the recording and wall API endpoints makes the effective search space much larger. Cloudflare's bot detection and Vercel's built-in DDoS protection would terminate any meaningful brute-force attempt long before it had any statistical chance of hitting a valid slug.
What happens when the request is resolved#
For the testimonial recording page, the slug maps to a testimonial_requests row:
const [requestRow] = await db
.select({ id, userId, title, status, slug })
.from(testimonialRequests)
.where(eq(testimonialRequests.uniqueLinkSlug, slug))
.limit(1);
if (!requestRow) {
return NextResponse.json({ error: "Request not found" }, { status: 404 });
}
if (requestRow.status !== "active") {
return NextResponse.json(
{ error: "This request is no longer accepting submissions" },
{ status: 410 },
);
}
Two checks after the slug lookup:
- The row must exist — a missing slug returns 404, revealing nothing about the system.
- The request status must be
"active"— paused and completed requests return 410 (Gone). This is the revocation mechanism: the owner doesn't need to delete the link, just set the status topausedorcompletedin their dashboard.
For the wall embed, the slug maps to a wall_of_love_configs row. If the wall config doesn't exist, the API returns 404 and the embed script renders an empty state.
Robots noindex on the recording pages#
The recording pages are marked noindex in the layout metadata:
// app/record/layout.tsx
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
nocache: true,
},
};
This isn't the primary security layer — the entropy of the slug is — but it keeps the pages out of search engine indexes, which means they can't be discovered by crawling links. A customer who receives a recording link and happens to tweet it won't cause it to be indexed.
The tradeoffs compared to OAuth#
The advantage of the slug pattern is that it requires nothing from the user — no account, no session, no token refresh. Click and you're in. This is the entire reason testimonial completion rates go up when you remove the login wall: there is literally no auth step.
The tradeoffs:
Revocation is link-level, not token-level. You can't revoke access for a specific person while keeping it for others. Changing the status affects everyone. For a testimonial recording page this is fine — the use case is "this request is now closed," not "block this specific person." For an embed wall, generating a new slug invalidates all existing embeds immediately, so customers need to update their embed code.
Sharing the link = sharing the access. If a customer forwards the recording link, anyone who receives it can submit a testimonial. The mitigating factors: submissions require approval before appearing on any wall, and the submission form captures the submitter's name and email, so fraudulent submissions are easy to identify and reject.
No audit log at the URL level. With OAuth, you can see exactly which user accessed what and when. With a slug, you know the slug was accessed but not by whom. For a public testimonial recording page this is acceptable — you don't need to know which of your customers clicked the link first.
Where we'd add stronger auth#
If we needed to restrict a recording link to a specific person — say, for an enterprise customer who didn't want their testimonial request discoverable even by accident — we'd add a one-time token verification step. The link would contain both the slug and a single-use token. On first access, the token is burned and a session cookie is set. Subsequent accesses use the session.
We haven't built this because the customer feedback hasn't required it. The entropy of the slug is sufficient security for how testimonial collection actually works. The recording link is sent directly to a customer you've chosen, not broadcast.
The slug column in the database#
The slug is stored as text with a unique index:
CREATE UNIQUE INDEX testimonial_requests_unique_link_slug_key
ON testimonial_requests(unique_link_slug);
Lookups are O(log n) via the index. We never scan the entire table. The UNIQUE constraint prevents accidental collisions — if generateSlug() happened to produce the same value twice (probability negligible but non-zero over millions of slugs), the INSERT would fail and the application would retry with a new slug.
Frequently asked
Quick answers
What if a customer shares the recording link publicly? Can anyone else submit a testimonial?+
Yes — anyone with the link can submit. This is by design for the recording flow. The recording page is for a specific customer but it's not locked to their identity. In practice this isn't a problem because the link is only sent to customers you've already chosen to ask, and testimonials are pending until you approve them. Any spam submissions get rejected and don't affect your public wall.
How do you revoke access to a recording link or embed wall?+
For recording links, set the request status to 'paused' or 'completed' — the API checks status before accepting submissions. For embed walls, generating a new embed slug invalidates the old one immediately. The old URLs return 404 from the API; any cached embed scripts will fail their next fetch and show an error state.
Isn't storing an unguessable slug in the database enough? Why not use UUID v4 directly?+
UUID v4 is also random and would also work. We use a shorter custom slug (10 chars from a 36-char alphabet) for two reasons: URLs that users see in the browser should be short and copyable, and we wanted to control the character set explicitly to avoid characters that cause issues in URLs or are visually confusing (0 vs O, 1 vs l). UUID v4's 122 bits of randomness is overkill for this use case; 10 × log2(36) ≈ 51.7 bits is more than enough.