Authentication
Sep 2, 2025

Passwordless authentication with Solid.js

Hrishikesh Premkumar
Founding Architect

TL;DR:

  • SSR-first protection: SolidStart guards routes with redirect() before hydration so kiosks never flash unauthenticated attendee data.
  • Unified API routes: /api/auth/send|resend|verify|me|logout return a normalized { success, data?, error? } shape, simplifying error handling and debugging.
  • Centralized context: AuthContext with signals, TTL caching, and SWR refresh keeps login state consistent across pages while reducing network load.
  • Scalekit integration: A singleton Scalekit client plus secure session helpers issue OTPs and magic links reliably, with HttpOnly cookies for kiosk-safe sessions.
  • Production hardening: CSRF checks, Redis-backed rate limiting, error boundaries, and caching strategies make the system resilient under bursty event traffic.

From long queues to instant access: Passwordless at expo kiosks

Picture a large technology expo with dozens of registration kiosks printing badges for thousands of attendees. Each attendee comes from a different company or community, with no pre-created accounts. The line moves slowly as some people fumble through creating passwords, others forget what they entered yesterday, and staff waste time troubleshooting accounts instead of helping with the event itself. Organizers tried offering a social login option, but not every attendee has compatible accounts, and shared devices at kiosks make SSO flows unreliable. They need a system that works instantly, doesn’t rely on remembered passwords, and still enforces strict security across every kiosk.

This is where passwordless authentication becomes more than a convenience. By delivering a one-time code or magic link directly to a verified email, the system eliminates forgotten passwords while still protecting server-rendered routes. Solid.js, with its SSR-first approach, ensures that no attendee data ever flashes unauthenticated during hydration. Scalekit provides the infrastructure to send OTPs and links reliably, while SolidStart gives developers composables, context, and server routes to wire it together cleanly.

In this write-up, you’ll see exactly how to build such a system. We’ll cover:

  • How to design API routes in SolidStart for sending, resending, and verifying credentials.
  • How to centralize state with signals and context so every component knows if an attendee is authenticated.
  • How to enforce server-side route protection, avoiding client-side hacks.
  • How to add resilience with error boundaries and caching strategies.

By the end, you’ll understand how to implement passwordless authentication in a SolidStart SSR app using Scalekit, not as a toy example, but as a robust pattern you can adapt to real environments like pop-up event booths, hospital kiosks, or any scenario where logins must be instant, secure, and invisible to the end user. You can also explore the [full sample app on GitHub to follow along with the code as you read this guide.

Architecture overview mapped to the event booth scenario

Imagine the registration kiosk app at the expo. It must decide, before showing any attendee data, whether the user is authenticated. If the design leans too heavily on the client, unverified screens might flash sensitive sponsor or attendee details during hydration. If the design mixes client logic and server logic loosely, developers end up patching security holes instead of shipping features. SolidStart’s architecture gives us the right primitives to avoid both problems: server-first APIs, SSR queries, and signal-based state management.

In a Vue/Nuxt environment, developers lean on composables, Pinia, and server API routes. SolidStart provides parallel patterns, but mapped into its own primitives. Here’s how the mapping works:

Concern
Nuxt/Vue pattern
SolidStart approach in this guide
Composables & state
Composables + Pinia
Context + Signals + createResource with TTL
Server endpoints
Nuxt server API routes
src/routes/api/* with export async function handlers
Route protection
Middleware + navigateTo
Server query + throw redirect() before hydration
Error handling
error.vue
<ErrorBoundary> and localized fallbacks
SSR & hydration
Nuxt SSR state injection
Server query preload into <AuthProvider>
Performance
Nuxt cachedFunction
TTL cache + background refresh (SWR-style)

By tying these architectural pieces together, we ensure that the event booth app delivers instant logins, hides sensitive data until sessions are validated, and remains simple for developers to extend. Each subsequent section of this write-up will delve into one of these areas, connecting the SolidStart primitives to the real challenges of fast, high-turnover authentication at public kiosks.

Project file layout and setup for the event booth app

To keep the expo kiosk app easy to maintain under pressure, the file layout is intentionally simple. Developers working at the event should be able to glance at the folder structure and immediately know where the auth logic, UI, and server routes live. This clarity is particularly helpful when kiosks need to be reset, when debugging must occur mid-event, or when the team hands off the app to a different contractor.

Here is the layout we will follow:

src
├── context
│   └── AuthContext.tsx          # centralized signals and context
├── routes
│   ├── index.tsx                # attendee email entry form
│   ├── passwordless
│   │   └── verify.tsx           # OTP or magic link verification screen
│   ├── dashboard.tsx            # protected page (badge data, sponsor info)
│   └── api
│       └── auth
│           ├── send.ts          # send login email
│           ├── resend.ts        # resend code or link
│           ├── verify.ts        # verify OTP or magic link
│           ├── me.ts            # return current session user
│           └── logout.ts        # clear session
├── scalekit.ts                  # Scalekit singleton client
├── session.ts                   # cookie + session helpers
├── components
│   └── ui
│       ├── Button.tsx
│       ├── Input.tsx
│       ├── Card.tsx
│       ├── Alert.tsx
│       ├── Protected.tsx
│       └── AuthFormBoundary.tsx
└── root.tsx

Environment and dependency setup

Event organizers often deploy kiosks on different networks, so configuration needs to be environment‑driven and portable. We use .env to keep credentials and base URLs flexible:

SCALEKIT_ENV_URL=YOUR_ENV_URL SCALEKIT_CLIENT_ID=YOUR_CLIENT_ID SCALEKIT_CLIENT_SECRET=YOUR_CLIENT_SECRET SESSION_SECRET=dev_secret_change APP_BASE_URL=http://localhost:3000

Dependencies are minimal:

npm install @scalekit-sdk/node

This ensures fast installation and avoids complex dependency resolution on machines that may be set up just before the event. By the end of setup, developers can spin up a kiosk in minutes, confident that authentication will work out of the box.

Scalekit singleton and session helpers for kiosk reliability

At an event booth, reliability is everything. Each kiosk may be restarted multiple times during the day, so the authentication logic must be centralized, minimal, and predictable. Two building blocks make this possible: a single Scalekit client instance and a clear set of session helpers that work across all routes.

Scalekit singleton

We create a single scalekit.ts file that initializes the SDK with environment variables. This avoids repeating credentials and ensures every API route uses the same configuration.

// src/scalekit.ts import { Scalekit } from "@scalekit-sdk/node"; const env = (key: string) => { const val = process.env[key]; if (!val) throw new Error(`${key} missing`); return val; }; export const scalekit = new Scalekit( env("SCALEKIT_ENV_URL"), env("SCALEKIT_CLIENT_ID"), env("SCALEKIT_CLIENT_SECRET") );

By isolating this setup, we make it easier to rotate secrets or switch environments (e.g., staging vs production) without touching multiple files.

Session helpers

Sessions keep attendees authenticated across page reloads and kiosk restarts. SolidStart’s server functions integrate with Vinxi’s useSession API, letting us manage cookies with HttpOnly flags. This ensures that even if a kiosk is compromised, the session cookie cannot be accessed by client-side scripts.

// src/session.ts "use server"; import { useSession } from "vinxi/http"; import { redirect } from "@solidjs/router"; export type SessionData = { email?: string }; const sessionOpts = { password: process.env.SESSION_SECRET as string, name: "sid", cookie: { httpOnly: true, sameSite: "lax" as const, secure: process.env.NODE_ENV === "production", path: "/", maxAge: 60 * 60 * 24 * 7, // 7 days }, }; export async function getAuthSession() { return useSession(sessionOpts); } export async function requireUserEmail() { const s = await getAuthSession(); if (!s.data.email) throw redirect("/"); return s.data.email; } export async function setUserEmail(email: string) { const s = await getAuthSession(); await s.update({ email }); } export async function clearUser() { const s = await getAuthSession(); await s.clear(); }

These helpers enforce the same rules everywhere: logins are stored in a secure cookie, SSR routes can block unauthenticated users immediately, and logouts are guaranteed to clear state completely. For an event setting where machines are shared and reused constantly, this consistency is non-negotiable.

Designing API routes for a kiosk-grade passwordless flow

Event kiosks need predictable server endpoints. SolidStart exposes “file-based API routes” where each src/routes/api/... file exports HTTP verbs (e.g., export async function POST). These handlers run on the server, so they can call Scalekit securely, set HttpOnly cookies, and return normalized JSON. A normalized JSON shape ({ success, data?, error? }) simplifies client code and makes error boundaries consistent.

Unified response contract for every endpoint

Use one discriminated union everywhere. This keeps UI code boring, in a good way.

// src/types/api.ts export type ApiResult = | { success: true; data: T } | { success: false; error: string };

Send route: create an authentication request

The “send” endpoint accepts an email and triggers Scalekit to email an OTP and/or magic link. The magiclinkAuthUri must point to your verify page so the link lands in-app. 

// src/routes/api/auth/send.ts import type { APIEvent } from "@solidjs/start/server"; import { scalekit } from "~/scalekit"; import type { ApiResult } from "~/types/api"; type SendOut = { authRequestId: string; expiresAt: number; expiresIn: number; passwordlessType: "OTP" | "LINK" | "LINK_OTP"; }; export async function POST(event: APIEvent) { try { const { email } = await event.request.json(); if (!email) throw new Error("email_required"); const options = { template: "SIGNIN" as const, expiresIn: 300, magiclinkAuthUri: `${process.env.APP_BASE_URL}/passwordless/verify`, }; const r = await scalekit.passwordless.sendPasswordlessEmail(email, options); const body: ApiResult = { success: true, data: { authRequestId: r.authRequestId, expiresAt: r.expiresAt, expiresIn: r.expiresIn, passwordlessType: r.passwordlessType, }, }; return Response.json(body); } catch (e: any) { const err: ApiResult = { success: false, error: e?.message ?? "send_failed" }; return Response.json(err, { status: 400 }); } }
Email submission triggers Scalekit send

This diagram shows how an attendee’s email submission triggers Scalekit to deliver both an OTP and a magic link, with the authRequestId stored for later verification.

Resend route: respect the original authentication request

Resends take only the authRequestId. This keeps the flow stateless and debuggable at the kiosk.

// src/routes/api/auth/resend.ts import type { APIEvent } from "@solidjs/start/server"; import { scalekit } from "~/scalekit"; import type { ApiResult } from "~/types/api"; export async function POST(event: APIEvent) { try { const { authRequestId } = await event.request.json(); if (!authRequestId) throw new Error("auth_request_id_required"); const r = await scalekit.passwordless.resendPasswordlessEmail(authRequestId); return Response.json({ success: true, data: r } satisfies ApiResult); } catch (e: any) { return Response.json({ success: false, error: e?.message ?? "resend_failed" } as ApiResult, { status: 400 }); } }

Verify route: accept either OTP code or magic link token

Verification sets the server session (HttpOnly cookie) so SSR routes can trust req state. The handler accepts { code } or { linkToken }, plus optional authRequestId when same-origin enforcement is enabled.

// src/routes/api/auth/verify.ts import type { APIEvent } from "@solidjs/start/server"; import { scalekit } from "~/scalekit"; import { setUserEmail } from "~/session"; import type { ApiResult } from "~/types/api"; type VerifyOut = { email: string }; export async function POST(event: APIEvent) { try { const { code, linkToken, authRequestId } = await event.request.json(); if (!code && !linkToken) throw new Error("missing_verifier"); const result = await scalekit.passwordless.verifyPasswordlessEmail( code ? { code } : { linkToken }, authRequestId ); await setUserEmail(result.email); return Response.json({ success: true, data: { email: result.email } } as ApiResult); } catch (e: any) { return Response.json({ success: false, error: e?.message ?? "verify_failed" } as ApiResult, { status: 400 }); } }
OTP verification and session persistence

This flow illustrates how an entered OTP is verified against Scalekit and persisted as a secure HttpOnly session cookie before redirecting to the dashboard.

Me route: return the current session in a kiosk-safe shape

Kiosks poll this endpoint with a short TTL cache. The response is compact and privacy-aware.

// src/routes/api/auth/me.ts import type { APIEvent } from "@solidjs/start/server"; import { getAuthSession } from "~/session"; import type { ApiResult } from "~/types/api"; type MeOut = { email: string | null }; export async function GET(_: APIEvent) { const s = await getAuthSession(); const email = s.data.email ?? null; return Response.json({ success: true, data: { email } } as ApiResult); }

Logout route: clear the session completely

A clean logout is essential on shared devices and during kiosk turnover.

// src/routes/api/auth/logout.ts import type { APIEvent } from "@solidjs/start/server"; import { clearUser } from "~/session"; import type { ApiResult } from "~/types/api"; export async function POST(_: APIEvent) { await clearUser(); return Response.json({ success: true, data: true } as ApiResult); }

Why this design holds up at busy booths

  • Idempotent flows: send → verify works the same whether a user enters an OTP or clicks a magic link.
  • Consistent errors: UI can route every failure through a single error boundary, improving operator guidance.
  • SSR trust: Session is set server-side, so /dashboard can gate access before hydration, preventing data flashes.
  • Operational clarity: Each route owns one job, which helps on-call devs triage kiosks quickly during peak check-in.

Client state and AuthContext: signals, TTL cache, and SWR-style refresh

Kiosks hammer the same actions: send email, verify, poll “Am I logged in?”, and logout. Centralizing that logic avoids duplicated fetches and inconsistent UI. In Solid, signals (primitive reactive state) and resources (async state with caching) make this clean. We’ll expose a small AuthContext that every page/component can call, and we’ll cache /api/auth/me with a short TTL (time-to-live) plus a background SWR (stale-while-revalidate) refresh. The result: fewer network calls during rush hours and instant UI updates after verification.

The context: one source of truth for kiosk auth

// src/context/AuthContext.tsx import { createContext, useContext, createSignal, createResource, onCleanup } from "solid-js"; export type User = { email: string } | null; function ttlFetcher(ttlMs: number) { let cache: { at: number; data: User } | null = null; let timer: number | undefined; const fetchMe = async () => { const now = Date.now(); if (cache && now - cache.at < ttlMs) return cache.data; const r = await fetch("/api/auth/me", { credentials: "include" }); const j = await r.json(); cache = { at: now, data: j.data?.email ? { email: j.data.email } : null }; clearTimeout(timer); timer = setTimeout(async () => { try { const r2 = await fetch("/api/auth/me", { credentials: "include" }); const j2 = await r2.json(); cache = { at: Date.now(), data: j2.data?.email ? { email: j2.data.email } : null }; } catch {} }, ttlMs + 50) as unknown as number; onCleanup(() => clearTimeout(timer)); return cache.data; }; return fetchMe; } type Ctx = { user: () => User; pendingEmail: () => string | null; authRequestId: () => string | null; send: (email: string) => Promise; resend: () => Promise; verifyCode: (code: string) => Promise; verifyLink: (linkToken: string) => Promise; refresh: () => void; logout: () => Promise; }; const AuthCtx = createContext(); export function AuthProvider(props: { children: any; initialUser?: User }) { const [pendingEmail, setPendingEmail] = createSignal(null); const [authRequestId, setAuthRequestId] = createSignal(null); const [bump, setBump] = createSignal(0); const [user] = createResource(bump, ttlFetcher(10_000), { initialValue: props.initialUser ?? null, }); async function send(email: string) { const r = await fetch("/api/auth/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }), }); const j = await r.json(); if (!j.success) throw new Error(j.error); setPendingEmail(email); setAuthRequestId(j.data.authRequestId); } async function resend() { const id = authRequestId(); if (!id) return; await fetch("/api/auth/resend", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ authRequestId: id }), }); } async function verifyCode(code: string) { const r = await fetch("/api/auth/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code, authRequestId: authRequestId() }), }); const j = await r.json(); if (!j.success) throw new Error(j.error); setPendingEmail(null); setAuthRequestId(null); setBump(n => n + 1); // re-check /me } async function verifyLink(linkToken: string) { const r = await fetch("/api/auth/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ linkToken, authRequestId: authRequestId() }), }); const j = await r.json(); if (!j.success) throw new Error(j.error); setPendingEmail(null); setAuthRequestId(null); setBump(n => n + 1); } async function logout() { await fetch("/api/auth/logout", { method: "POST" }); setBump(n => n + 1); } return ( user() ?? null, pendingEmail, authRequestId, send, resend, verifyCode, verifyLink, refresh: () => setBump(n => n + 1), logout, }} > {props.children} ); } export const useAuth = () => { const v = useContext(AuthCtx); if (!v) throw new Error("AuthProvider missing"); return v; };

Why this works at crowded booths

  • Fast perceived latency: a 10-second TTL hides network jitter; SWR updates in the background.
  • Predictable UX: the context exposes one API (send, resend, verify, logout, refresh) used by all screens.
  • Hydration-friendly: pass initialUser from SSR (we’ll wire that in the next section) so the first render matches server state.
Client side auth state ttel cache plus swr refresh of api, auth, me

This diagram explains how the AuthContext caches /me responses with a TTL while using background SWR refreshes to keep state accurate under load.

SSR + hydration: preload the user and guard routes before the UI renders

Kiosk screens must never flash attendee data to unauthenticated users. SolidStart lets us run server queries before rendering and pass that state into the app for hydration. The pattern has two parts:

  1. Inject initial user on first render so the client and server agree.
  2. Enforce access on the server using a query that throws redirect() before any HTML is streamed.

Inject the initial user into the app

root.tsx runs once and can call a server query to read the session cookie and return { email } | null. We pass that into AuthProvider as initialUser, so the first client render already knows whether the kiosk is authenticated.

// src/root.tsx import { Router, FileRoutes } from "@solidjs/router"; import { createAsync, query } from "@solidjs/router"; import { AuthProvider } from "./context/AuthContext"; import { useSession } from "vinxi/http"; type SessionData = { email?: string }; const getInitialUser = query(async () => { "use server"; const s = await useSession({ password: process.env.SESSION_SECRET as string, name: "sid", }); return s.data.email ? { email: s.data.email } : null; }); export default function Root() { const me = createAsync(() => getInitialUser()); return ( ); }

Why it matters at booths: the very first paint matches the server's truth. There’s no “logged out → logged in” flicker that leaks attendee names on shared devices.

Block access server-side on protected routes

For pages like /dashboard that render badge data or sponsor entitlements, guard before hydration. A server query can read the session and throw redirect("/") if unauthenticated.

// src/routes/dashboard.tsx import { createAsync, query, redirect } from "@solidjs/router"; import { useSession } from "vinxi/http"; import type { JSX } from "solid-js"; type SessionData = { email?: string }; const getUserOrRedirect = query(async () => { "use server"; const s = await useSession({ password: process.env.SESSION_SECRET as string, name: "sid", }); if (!s.data.email) throw redirect("/"); return { email: s.data.email }; }); export default function Dashboard(): JSX.Element { const u = createAsync(() => getUserOrRedirect()); return (

Welcome, {u()?.email}

{/* dashboard content */}
); }

Why this works under load:

  • Zero client race conditions: the server decides access; the client just renders.
  • No data flash: unauthenticated requests never reach the dashboard’s JSX render path.
  • Operational clarity: if kiosks are misconfigured (missing cookie), they consistently land on /.
sse query guard prevents unauthorized dashboard render

This flow highlights how a server-side query blocks unauthenticated users from ever reaching the dashboard, preventing data flashes during hydration.

Client safety net (optional)

You can keep a light <Protected> wrapper for client navigation initiated after load (e.g., SPA transitions). The server guard remains the source of truth.

// components/ui/Protected.tsx import { Show } from "solid-js"; import { useNavigate } from "@solidjs/router"; import { useAuth } from "~/context/AuthContext"; export default function Protected(props: { children: any; fallback?: any }) { const { user } = useAuth(); const nav = useNavigate(); const u = user(); if (!u) nav("/"); return {props.children}; }

Kiosk checklist for SSR + hydration

  • Pass initialUser into AuthProvider from a server query in root.tsx.
  • Guard protected pages with a server query that throws redirect() before rendering.
  • Keep client wrappers minimal; the server decides access.

UI flows: email entry, verification, and accessible errors

The kiosk UI has two primary screens: the email entry and the verification screen. The dashboard stays server-guarded. Keep the UI minimal (large targets, no distractions) and resilient (clear errors, safe resends).

Email entry (/): start the flow

  • Collect a valid email.
  • Call send(email) from the context.
  • Show a resend button bound to authRequestId.
  • Surface errors via a local error boundary with aria-live.
// src/routes/index.tsx import { Show, createSignal } from "solid-js"; import AuthFormBoundary from "~/components/ui/AuthFormBoundary"; import { useAuth } from "~/context/AuthContext"; export default function Index() { const { send, pendingEmail, resend } = useAuth(); const [email, setEmail] = createSignal(""); const [sent, setSent] = createSignal(false); const [busy, setBusy] = createSignal(false); async function onSubmit(e: Event) { e.preventDefault(); setBusy(true); try { await send(email()); setSent(true); } finally { setBusy(false); } } return ( <main class="p-6 max-w-md mx-auto"> <h1 class="text-2xl font-semibold mb-4">Sign in without a password</h1> <AuthFormBoundary> <form class="space-y-3" onSubmit={onSubmit} novalidate> <input autocomplete="email" type="email" required value={email()} onInput={(e) => setEmail(e.currentTarget.value)} placeholder="you@example.com" class="border p-3 w-full rounded" aria-label="Email address" /> <button disabled={busy()} type="submit" class="border rounded px-4 py-2"> {busy() ? "Sending..." : "Send code or link"} </button> </form> <Show when={sent()}> <p class="mt-3"> We sent a code and magic link to >strong>{pendingEmail()}</strong>. <button class="underline ml-2" onClick={() => resend()}>Resend</button> </p> </Show> </AuthFormBoundary> </main> ); }

Verification (/passwordless/verify): OTP or Magic Link

  • If a user clicks the magic link, the browser lands at /passwordless/verify?link_token=.... Detect link_token on mount and call verifyLink.
  • If the user prefers OTP, provide a numeric code field and call verifyCode.
  • On success, navigate to /dashboard (which is still server-guarded).
// src/routes/passwordless/verify.tsx import { onMount, createSignal, Show } from "solid-js"; import { useSearchParams, useNavigate } from "@solidjs/router"; import { useAuth } from "~/context/AuthContext"; import AuthFormBoundary from "~/components/ui/AuthFormBoundary"; export default function Verify() { const [params] = useSearchParams(); const nav = useNavigate(); const { verifyCode, verifyLink } = useAuth(); const [code, setCode] = createSignal(""); const [busy, setBusy] = createSignal(false); const [status, setStatus] = createSignal<"idle"|"verifying"|"done">("idle"); onMount(async () => { const lt = params["link_token"]; if (lt) { setStatus("verifying"); try { await verifyLink(lt); setStatus("done"); nav("/dashboard"); } catch { setStatus("idle"); } } }); async function submit(e: Event) { e.preventDefault(); setBusy(true); try { await verifyCode(code()); setStatus("done"); nav("/dashboard"); } finally { setBusy(false); } } return ( <main class="p-6 max-w-md mx-auto"> <h1 class="text-2xl font-semibold mb-4">Verify your email</h1> <AuthFormBoundary> <form class="space-y-3" onSubmit={submit}> <input inputmode="numeric" pattern="[0-9]*" placeholder="6-digit code" value={code()} onInput={(e) => setCode(e.currentTarget.value)} class="border p-3 w-full rounded" aria-label="One-time code" /> <button disabled={busy()} type="submit" class="border rounded px-4 py-2"> {busy() ? "Verifying..." : "Verify code"} </button> </form> <Show when={status() === "verifying"}><p class="mt-3">Verifying magic link...</p></Show> <Show when={status() === "done"}><p class="mt-3">Verified. Redirecting...</p></Show> </AuthFormBoundary> </main> ); }

Error boundaries and accessible alerts

  • Use a small boundary that renders an aria-live="assertive" message so kiosk operators see and screen readers announce errors immediately.
  • Keep messages actionable (e.g., “Code expired, tap Resend”).
// components/ui/AuthFormBoundary.tsx import { ErrorBoundary } from "solid-js"; export default function AuthFormBoundary(props: { children: any }) { return ( <ErrorBoundary fallback={(e) => ( <p role="alert" aria-live="assertive" class="mt-3 text-red-600"> {(e as Error).message ?? "Something went wrong. Please try again."} </p> )} > {props.children} </ErrorBoundary> ); }

Practical UX notes for booths

  • Debounce resends at the UI (e.g., disable the resend button for ~10–15s) to respect backend rate limits and reduce accidental spam.
  • Persist authRequestId implicitly in the AuthContext, so resends work even after minor navigation.
  • Large tap targets and clear focus states help on touchscreens under time pressure.
  • Stateless verify page that accepts either flow lowers support load: users can re-open the link or re-enter a code without special paths.

Production hardening and performance for busy event booths

Security posture must be explicit. Shared kiosks magnify risk; assume anyone can walk up and press buttons. Use HttpOnly cookies (already in session.ts), set secure in production, and lock POST routes behind CSRF or same-origin checks. If you don’t want full CSRF middleware, at least require and verify a nonce header you mint server-side and rotate on login.

// minimalist CSRF check (per-request nonce in a cookie + header) function requireCsrf(req: Request) { const cookie = req.headers.get("cookie") ?? ""; const token = /csrftok=([^;]+)/.exec(cookie)?.[1]; const header = req.headers.get("x-csrf-token"); if (!token || token !== header) throw new Error("csrf_violation"); }

Rate limiting prevents abuse and keeps lines moving. Use a persistent store (Redis) so limits survive kiosk restarts. Apply limits on /api/auth/send and /api/auth/resend, keyed by email + IP + kiosk ID. Keep UI buttons disabled for ~10–15 seconds after a send to reduce accidental double-clicks.

// pseudo: sliding window limit -- 5 actions / 2 min const key = `lim:${email}:${ip}`; const now = Date.now(); await redis.zadd(key, { score: now, value: `${now}` }); await redis.zremrangebyscore(key, 0, now - 120_000); const count = await redis.zcard(key); if (count > 5) throw new Error("rate_limited"); await redis.expire(key, 180);

Attempt throttling should match your verifier. Scalekit enforces five OTP attempts in a ten-minute window for a given auth_request_id. Mirror that in UI copy (“Too many attempts, request a new code”) and in logging so operators can explain failures quickly at the booth.

Caching /api/auth/me reduces load. You already have a 10-second TTL and SWR refresh client-side. Add HTTP caching hints for intermediaries you control (but keep private to avoid shared caches). Consider ETag when you persist more than email.

// /api/auth/me return new Response(JSON.stringify(body), { headers: { "Content-Type": "application/json", "Cache-Control": "private, max-age=5, stale-while-revalidate=25", }, });

Headers harden defaults without extra dependencies. Send X-Frame-Options: DENY, Referrer-Policy: same-origin, and a strict Content-Security-Policy that only allows your own origin plus the Scalekit API host. Lock down Permissions-Policy to disable sensors and camera on kiosks.

Operational visibility matters during rush hours. Log a compact audit trail: timestamp, kioskId, action(send|verify|resend|logout), emailHash, result(ok|err:code). Avoid storing raw emails in logs; hash with a stable salt. Ship logs to a central endpoint so floor leads can spot patterns (e.g., one kiosk failing DNS).

Performance wins are small but stackable. Pre-connect DNS to Scalekit, keep API handlers small, and avoid unnecessary JSON parsing on hot paths. On the UI, mount lightweight pages and defer any non-auth scripts until after verification. For resilience, keep a prominent “Reset kiosk” link that clears session and reloads / in one click for staff.

What you’ve locked down: CSRF or origin checks on POST, persistent rate limiting, aligned OTP attempt limits, cookie security in production, cache hints for /me, hardened security headers, minimal logging with privacy, and run-book-friendly behavior under real event pressure. This is the last mile between a working demo and a kiosk-grade, production-ready passwordless system.

Error handling with Solid boundaries in a kiosk environment

In Nuxt, developers are used to a top-level error.vue and optional route-level boundaries. SolidStart gives similar flexibility with <ErrorBoundary> components. For event kiosks, error handling is not just developer convenience; it determines how quickly an operator can recover when a login fails under pressure.

Global boundary

Wrap the entire app in a root boundary so unexpected server or network failures don’t render a blank screen. Place it in root.tsx just outside the router.

// root.tsx (simplified) import { ErrorBoundary } from "solid-js"; export default function Root() { return ( <ErrorBoundary fallback={(err) => ( <main class="p-6"> <h1 class="text-xl font-bold text-red-600">System error</h1> <p>{(err as Error).message}</p> </main> )}> <Router> <AuthProvider initialUser={me()}> <FileRoutes /> </AuthProvider> </Router> </ErrorBoundary> ); }

This ensures that if a kiosk hits an unexpected state (bad env, server crash), the operator sees a clear message instead of an infinite spinner.

Local boundaries for auth forms

Local boundaries catch predictable, user-facing failures such as invalid codes, expired links, or resend limits. The AuthFormBoundary component already implements this pattern:

// components/ui/AuthFormBoundary.tsx import { ErrorBoundary } from "solid-js"; export default function AuthFormBoundary(props: { children: any }) { return ( <ErrorBoundary fallback={(e) => ( <p role="alert" aria-live="assertive" class="mt-3 text-red-600"> {(e as Error).message ?? "Something went wrong. Please try again."} </p> )} > {props.children} </ErrorBoundary> ); }

Every form wrapped in this boundary can throw errors (e.g., from send, resend, verify), and the UI will show accessible inline feedback without breaking the rest of the app.

Why boundaries matter at kiosks

  • Faster recovery: Operators don’t have to restart kiosks; they can see and explain errors on screen.
  • Accessibility: Errors announced via aria-live help ensure usability for all attendees.
  • Consistency: Global boundary = last line of defense, local boundary = predictable UI feedback.

This mirrors what Nuxt developers achieve with error.vue and route-level error pages, but in SolidStart, the granularity is fully component-driven.

Performance optimization and caching for high-throughput kiosks

Kiosk traffic is bursty: doors open, everyone signs in, then traffic drops. Optimize for short, intense spikes without sacrificing correctness.

  • Cache “who am I” aggressively: Keep a 10s TTL in AuthContext for /api/auth/me, plus SWR (background refresh). This masks network jitter while remaining fresh enough for badge printing. Pair client caching with private HTTP hints:
// /api/auth/me return new Response(JSON.stringify(body), { headers: { "Cache-Control": "private, max-age=5, stale-while-revalidate=25" } });
  • Debounce resends at the edge: Disable the “Resend” button for ~10–15s after a send. This aligns user behavior with backend limits and slashes duplicate traffic during rushes.
  • Keep API handlers tiny: Avoid extra JSON work on hot paths; validate input, call Scalekit, shape output, return. Smaller handlers mean lower tail latency under load.
  • Prefer server redirects over client guards: The server redirect() stops unauthenticated requests before JSX renders, eliminating wasted work and preventing data flashes that would thrash the client.
  • Batch UI state updates: In verification handlers, clear pendingEmail, authRequestId, and bump the resource once, to avoid multiple renders on low-power kiosk hardware.
  • Use connection reuse: In Node deployments, enable HTTP/1.1 keep-alive on your runtime (most adapters do by default). Reused sockets reduce handshake overhead during spikes.
  • Instrument the hot path lightly: Count /api/auth/send, /verify, and /me latency and errors. Even a minimal counter helps you spot a degrading kiosk or network segment before lines back up.

These tactics keep the sign-in loop snappy at peak load while preserving SSR correctness and privacy on shared devices.

Conclusion: bringing passwordless to the expo floor

In this article, we walked through the full journey of making SolidStart passwordless authentication work in the real world. We began with the scenario of pop-up event registration booths, where attendees without pre-created accounts cause bottlenecks and staff frustration. That story set the stage for why passwordless is not just trendy, it directly solves the pain of forgotten passwords, long queues, and insecure shared accounts.

From there, we unpacked the architecture. We mapped Nuxt concepts to SolidStart equivalents, laid out a clear project structure, and showed how to wire up a Scalekit singleton with secure session helpers. We built API routes for sending, resending, verifying, and clearing sessions. We centralized state in an AuthContext with signals and TTL/SWR caching, then enforced SSR route protection so kiosks never flash attendee data unauthenticated. We designed minimal UI flows for email entry and verification, wrapped them with error boundaries for resilience, and added performance optimizations that keep kiosks snappy even at peak traffic.

Together, these pieces solve the booth’s core problem: logins are instant, reliable, and invisible. Attendees type an email, operators get predictability, and sensitive sponsor or attendee data remains locked until authentication is confirmed, no matter how rushed the environment.

If you found this useful, you can:

  • Explore Scalekit’s documentation for deeper options like template customization and rate limiting integrations.
  • Check our other deep-dives on SSR security patterns and state management in Solid to extend these ideas.
  • Try the sample project attached to this write-up and adapt it to your own kiosk or dashboard environment.

Passwordless isn’t just a UX improvement; when built with Solid.js and Scalekit, it becomes a deployment pattern you can trust in the most demanding real-world scenarios.

FAQ

How does Scalekit ensure security when sending magic links or OTPs via email?

Scalekit enforces short-lived tokens, signed request validation, and optional same-origin enforcement on magic links. This means even if an OTP or link leaks, it expires quickly and can only be verified with the original auth_request_id.

Can I integrate Scalekit passwordless authentication with an existing session store like Redis?

Yes. Scalekit handles email delivery and verification, but you can persist sessions in Redis or any distributed store via SolidStart’s useSession hook. This gives resilience across kiosk restarts and scales horizontally under event traffic.

How does SolidStart prevent unauthenticated data flashes during SSR hydration?

SolidStart’s query + throw redirect() ensures route protection happens before HTML streams to the client. This blocks dashboards from rendering at all if no session cookie is present, eliminating the risk of sensitive attendee data flashing unauthenticated.

What caching strategies keep /api/auth/me performant under high kiosk load?

A hybrid of client TTL cache (e.g., 10s) and SWR-style background refresh reduces redundant polling. On the server, use Cache-Control: private, stale-while-revalidate headers. Together, they minimize network spikes while keeping identity state fresh.

How should I handle rate limiting for passwordless email sends in kiosk deployments?

Use a sliding window counter in Redis keyed by email+ip+kioskId to allow a fixed number of sends (e.g., 5 per 2 minutes). This prevents abuse, aligns with Scalekit’s resend rules, and keeps event infrastructure responsive under burst traffic.

No items found.
On this page
Share this article
Modernize login flows

Acquire enterprise customers with zero upfront cost

Every feature unlocked. No hidden fees.
Start Free
$0
/ month
1 FREE SSO/SCIM connection each
1000 Monthly active users
25 Monthly active organizations
Passwordless auth
API auth: 1000 M2M tokens
MCP auth: 1000 M2M tokens