We put a real, live dashboard on our homepage with no signup wall. Here's how it survives strangers.

typescript dev.to

You land on a SaaS product's homepage. It looks good. You want to see the actual dashboard before you decide whether five more minutes of your life are worth a signup form. There's no way to. "Book a demo." "Start free trial" (email required, sometimes a card). A carousel of static screenshots that were true eighteen months ago. So you close the tab, and the product loses you before you ever saw the product.

We didn't want to be that tab-close. So we put a real, live, populated workspace behind a "Try live demo" button on the homepage - no email, no account, one click, straight into the actual dashboard with actual-looking data: usage charts, dead-feature detection, an events feed. Not screenshots. The real app.

The problem with that idea is the second half of the sentence: the real app, open to anyone. A dashboard is normally only as trustworthy as its access control, and we were about to hand out a session to whoever clicked a button, pointed at data other visitors are looking at too. Here's what actually had to be true before that button could ship.

"Hide the buttons" is not a security model

The lazy version of a public demo is a normal account with the delete/edit buttons hidden in the UI. It's also worthless as protection, because nothing stops a visitor from opening devtools, finding the real API call behind that hidden button, and firing it directly. UI-level restrictions are a UX feature, not an access-control boundary - they describe what a well-behaved client does, not what the server allows.

So the read-only guarantee for our demo account isn't a frontend concern at all. It's a single interceptor sitting in front of every request the backend handles, checked after auth and before any handler runs:

const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);

@Injectable()
export class DemoReadOnlyInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const req = context.switchToHttp().getRequest<AppRequest>();

    if (req.user?.isDemoAccount && !SAFE_METHODS.has(req.method)) {
      throw new ForbiddenException('The demo account is read-only.');
    }

    return next.handle();
  }
}
Enter fullscreen mode Exit fullscreen mode

It's registered globally, once, next to the app's other cross-cutting interceptor. It doesn't know or care what the request was trying to do - create a project, delete a workspace, rotate an API key, invite a teammate. If the session belongs to the demo account and the method isn't GET/HEAD/OPTIONS, it's rejected before it reaches the controller. That's the whole surface. Every mutation endpoint in the app - workspaces, projects, invites, API keys, dashboard layout, even the admin routes - is covered by one check, not by remembering to add a guard to each one individually. The alternative (annotate every mutating endpoint by hand) is exactly the kind of thing that's correct on the day you write it and silently wrong the first time someone adds a new endpoint and forgets.

isDemoAccount itself is about as small as a security check gets:

export function isDemoAccount(email?: string): boolean {
  if (!email) return false;
  return email === process.env.DEMO_ACCOUNT_EMAIL;
}
Enter fullscreen mode Exit fullscreen mode

One env var, one comparison, computed once per request in the auth guard and stamped onto req.user. No table to keep in sync, no role to accidentally grant to the wrong person later.

The gap that wasn't in the interceptor - it was next to it

Here's the failure mode that actually worried us once the read-only guard existed: what if the demo account somehow also ended up with platform-admin rights? Those are two completely independent booleans, computed from two completely independent env vars (DEMO_ACCOUNT_EMAIL, PLATFORM_ADMIN_EMAIL), by two completely independent checks. Nothing links them - which is fine, until someone misconfigures one operator's environment and sets both to the same address. The read-only interceptor only blocks mutations. It says nothing about reads, and platform-admin routes are gated purely by isPlatformAdmin, not by isDemoAccount. A misconfiguration like that would hand a public, no-login-required visitor read access to internal operator tooling - workspace-wide billing internals, every customer's account list, whatever else "admin" means in a growing app.

That's not a bug you find by testing the happy path. It's a bug you find by asking "what has to be independently true for this to be safe, and is any of it actually coupled to anything else." The fix is one line, in the same place both flags get computed:

isPlatformAdmin: !demoAccount && isPlatformAdmin(session.user.email)
Enter fullscreen mode Exit fullscreen mode

Demo unconditionally wins. Even if an operator someday points both env vars at the same address, the account that resolves as "demo" can never also resolve as "platform admin" - it's not a policy documented in a runbook that says "don't do that," it's a line of code that makes the mistake structurally impossible instead of merely inadvisable.

A demo that doesn't visibly rot

A demo account that never gets new data is a different kind of broken - it's harder to notice, but "last used: 4 months ago" on every feature is its own way of telling a visitor the product is dead. So the demo workspace reseeds itself every hour, on a cron, wiping and regenerating its own analytics tables (aggregates, daily/hourly stats, the raw event feed - synthesized in memory as one internally-consistent event stream, not generated table by table) so "last used" always looks like today, whatever time zone the visitor is in.

The part worth calling out isn't the cron - it's what happens if two requests hit it at once, which will happen the moment you run more than one instance of the API:

const { rows } = await lockClient.query(
  'SELECT pg_try_advisory_lock($1)',
  [LOCK_ID],
);

if (!rows[0]?.pg_try_advisory_lock) return;
Enter fullscreen mode Exit fullscreen mode

A Postgres session-level advisory lock, held for the entire multi-transaction refresh. If a second instance's cron fires while the first is still mid-reseed, it just checks the lock, sees it's held, and returns immediately - no queueing, no duplicate writes, no two processes racing to regenerate the same rows. And if the reseed itself throws partway through, it's caught and logged, never rethrown - a failed tick just leaves last hour's data in place instead of leaving the demo workspace half-rewritten, and the next tick tries again.

What "done" required before this shipped

None of the above shipped on the strength of "looks right in a code review." Before we called it done:

  • Ran the actual seed script twice in a row against a local database and confirmed it doesn't duplicate the workspace, the projects, or double-count the current month's usage - a reseed job that isn't idempotent turns "every hour" into "growing forever."
  • Hit the real endpoints with a manually issued demo session cookie: GET /api/v1/auth/me (200 - reads work), POST /api/v1/workspaces (403 - mutation blocked), POST on the event-ingestion path (403 - can't be used to inject arbitrary events into the shared dataset either), GET on the analytics endpoints (200, real numbers back).
  • Wrote a dedicated collision test: set the demo-account env var and the platform-admin env var to the same address on purpose, and asserted the resulting session still comes back isPlatformAdmin: false. Not just "the normal case works" - the specific misconfiguration scenario, provoked deliberately.

The demo account also can't be used to bootstrap a live API key and start pushing real events into the shared workspace - there's deliberately no ProjectApiKey row created for the demo project during seeding, so there's no key for a visitor to find and copy. That one's a narrower guarantee than the interceptor (it's "we never created the door," not "the door is locked"), which is worth being honest about rather than overstating: the day a demo project would need a live key issued for some other reason, that assumption needs revisiting, not just re-trusting.

Try it

If you want to see what any of this looks like from the visitor's side rather than the request-log side: eventra.dev has a "Try live demo" link on the homepage and the sign-in page. One click, no account, into a populated workspace that's never more than an hour stale. Poke at it, try to break something - it's read-only by construction, not by convention, so go ahead.

Source: dev.to

arrow_back Back to Tutorials