Demo Credentials — Persimmon Patterns
Every Persimmon internal tool with auth ships the same two demo accounts on its staging/demo login page, in the same format, gated so they physically cannot appear in production. Goal: no one has to remember which login belongs to which client, and a misconfigured env var still can't leak creds to a live client domain.
Scope boundary:
- Auth wiring /
authorize()/ sessions →security-nextauth. - Rate-limiting the demo login (bots scrape demo creds) →
security-hardening. - The demo-creds standard + the env+host gate + the login-page UI → here.
The Persimmon standard (defaults)
| Role | Password | |
|---|---|---|
| Admin | admin@persimmon.local |
password |
| User | user@persimmon.local |
password123 |
Persimmon login forms take email (NextAuth credentials provider keys on email). Use the .local TLD — RFC 6762 reserved, guaranteed never to resolve to a real mailbox.
- Two roles only (admin + user) — "the boss + the employee." A third role adds cognitive load without clarifying anything.
- Simple memorable values — chosen for demo speed. Security comes from the env+host gate below, not password complexity. These never render in production.
- Never
admin/admin— identical strings for both fields read as "default never changed" and erode client trust.
Override per project only on client request; document overrides in the project's README.md.
Multi-role internal tools — one demo login per role. The two-role default is for the common "boss + employee" case. A role-based tool with more than two distinct roles (e.g. admin/lawyer/manager/viewer) should instead ship one demo account per role so a demoer can land directly in each role's view. Keep everything else identical — the copy-to-clipboard UX, the isDemo() env+hostname gate — just render one chip per role. These demo logins coexist with real DB-backed signup; see backend-account-management for the full account lifecycle they sit alongside.
When to display — defense in depth (env AND host)
The credentials block renders only when BOTH are true:
- A non-production app env (
APP_ENVisdemoorstaging). - The request hostname is on the Persimmon demo allowlist.
AND, not OR. A production deploy accidentally carrying APP_ENV=demo still won't leak, because a real client domain (sistema.piccino.com.br) isn't on the allowlist. Two independent failures required to leak.
Server-only gate
// src/lib/demo.ts — server module; never imported into a client component
import "server-only";
import { headers } from "next/headers";
const DEMO_HOSTS = new Set([
"localhost",
"127.0.0.1",
"staging.persimmon.dev",
// add the project's Railway preview/staging host here
]);
export async function isDemo(): Promise<boolean> {
const env = process.env.APP_ENV ?? "production";
if (env !== "demo" && env !== "staging") return false;
// Behind Railway's edge, the real host is x-forwarded-host (see security-nextauth).
const h = await headers();
const raw = h.get("x-forwarded-host") ?? h.get("host") ?? "";
const host = raw.split(":")[0].toLowerCase();
return DEMO_HOSTS.has(host);
}
export function demoCredentials() {
return [
{ role: "Admin", email: "admin@persimmon.local", password: "password" },
{ role: "User", email: "user@persimmon.local", password: "password123" },
] as const;
}
import "server-only" makes the build fail loudly if this module is ever pulled into a client bundle — so the hostname allowlist and gate logic can never ship to the browser.
Login page — gate on the server, pass to a client component
// src/app/login/page.tsx (Server Component)
import { isDemo, demoCredentials } from "@/lib/demo";
import { DemoCreds } from "./demo-creds";
import { LoginForm } from "./login-form"; // from security-nextauth
export const dynamic = "force-dynamic"; // reads headers() at request time
export default async function LoginPage() {
const showDemo = await isDemo();
return (
<main>
{showDemo && <DemoCreds creds={demoCredentials()} />}
<LoginForm />
</main>
);
}
Copy-to-clipboard client component — NOT autofill
Render above the form (Filament/AdminLTE consensus — never below, never beside):
// src/app/login/demo-creds.tsx
"use client";
import { useState } from "react";
type Cred = { role: string; email: string; password: string };
function CopyButton({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(value);
} catch {
// older-browser fallback: select so the user can Cmd/Ctrl+C
const r = document.createRange();
r.selectNodeContents(document.getElementById(`v-${value}`)!);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(r);
}
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
return (
<button
type="button"
className="relative inline-flex items-center gap-2 rounded border border-zinc-300 bg-white px-2 py-1 font-mono text-sm hover:border-zinc-500"
>
<span id={`v-${value}`}>{value}</span>
{copied && (
<span className="absolute inset-0 flex items-center justify-center rounded bg-green-600 font-sans font-semibold text-white">
Copiado
</span>
)}
</button>
);
}
export function DemoCreds({ creds }: { creds: readonly Cred[] }) {
return (
<aside
role="region"
aria-label="Credenciais de demonstração"
className="mb-5 rounded-md border border-amber-300 bg-amber-50 p-4"
>
<p className="mb-3 text-sm">
<strong>Ambiente de demonstração</strong> — qualquer pessoa pode entrar. Não insira dados reais.
</p>
{creds.map((c) => (
<div key={c.role} className="mb-2 grid grid-cols-[60px_1fr_1fr] items-center gap-2">
<span className="text-sm font-semibold">{c.role}</span>
<CopyButton value={c.email} />
<CopyButton value={c.password} />
</div>
))}
</aside>
);
}
Never autofill. Copy-only because:
- Training — forces a conscious "log in", building muscle memory for real creds.
- Hygiene — teaches that auto-fill-on-click is not normal (phishing defense).
- Clarity — the button shows which string maps to which field, no mangling surprises.
Seeding the demo users — hashes only, never plaintext in code
Reuse the security-nextauth seed shape. Passwords are documented in README.md; the seed stores bcryptjs hashes, never plaintext.
// prisma/seed.ts
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const db = new PrismaClient();
const DEMO = [
{ email: "admin@persimmon.local", name: "Admin", role: "admin", password: "password" },
{ email: "user@persimmon.local", name: "User", role: "user", password: "password123" },
];
async function main() {
// Guard: never seed demo accounts into a production database.
if ((process.env.APP_ENV ?? "production") === "production") {
throw new Error("Refusing to seed demo users with APP_ENV=production");
}
for (const u of DEMO) {
await db.user.upsert({
where: { email: u.email },
update: {},
create: {
email: u.email,
name: u.name,
role: u.role,
passwordHash: await bcrypt.hash(u.password, 12),
},
});
}
}
main().finally(() => db.$disconnect());
Run with APP_ENV=demo npx tsx prisma/seed.ts. Idempotent. The APP_ENV=production guard is a third independent safeguard — even the data layer refuses demo accounts in prod.
Production safety layers (in addition to the env+host gate)
robots: { index: false, follow: false }via Next metadata on demo deploys (robots.txt must NOT block — crawlers need to read the noindex tag).X-Robots-Tag: noindexheader on staging, set in middleware whenAPP_ENV !== "production".- Rate-limit demo logins — bots scrape demo creds; apply the login limiter from
security-hardening(5/IP/15 min). - Visible banner on every demo page: "DEMO — não usar com dados reais."
- Nightly demo reset — a Railway cron re-running
prisma/seed.ts(or a reset script) at 03:00 so demo data never accumulates. Seed lives in the repo; never hand-edit the demo DB. - Staging behind Railway access control or a separate Railway environment — keep the demo project off the production domain entirely.
README pattern
## Credenciais de demonstração (somente staging)
Exibidas na página de login em modo demo/staging. NUNCA em produção.
| Função | E-mail | Senha |
|--------|-------------------------|-------------|
| Admin | admin@persimmon.local | password |
| User | user@persimmon.local | password123 |
Rodar localmente: `APP_ENV=demo npm run dev`
Plaintext demo passwords live only in README.md. The seed stores hashes; the DB stores hashes.
Environment variables
APP_ENV=production # .env.example default — MUST be production
# Staging/demo deploys override to APP_ENV=demo in Railway env vars only
.env.example ships APP_ENV=production. Only a deliberate Railway env override flips a deploy to demo mode.
Anti-patterns banned
- Demo creds in JSX without the
isDemo()gate — production leak risk. - Gate on env OR host (instead of AND) — single misconfig leaks. Require both.
- Importing
src/lib/demo.tsinto a client component — leaks the allowlist; theserver-onlyguard exists to prevent exactly this. - Autofill instead of copy — breaks the training + hygiene rationale.
- Plaintext passwords in
seed.ts/ migration comments — they live forever in git history. Hashes only; plaintext only in README. .env.exampleshippingAPP_ENV=demo— default must beproduction.- A hardcoded
DEMO_MODE = trueconstant — no environment safety; ships to prod by accident. admin/admin— reads as "never configured", erodes trust.- More than 2 demo roles on a tool that only has 2 roles — cognitive load with no clarification. (A genuinely multi-role tool should show one demo login per role — see the multi-role note above.)
- Reading
hostinstead ofx-forwarded-hostbehind Railway — the gate checks the wrong (internal) hostname and misfires.
When NOT to use this skill
- Public marketing sites with no auth.
- Production user logins — real users go through the normal
authorize()flow with no demo display.
Cross-references
security-nextauth— theauthorize()flow, login form,x-forwarded-host, bcryptjs seed shapesecurity-hardening— rate-limit the demo login against scraping bots; session hardeningsecurity-review— audit catches demo creds shipped without the gate, andAPP_ENV=demoin.env.examplestack-zod-boundary— login input validationinfra-railway-deploy— staging env var (APP_ENV=demo), the nightly reset cron, separate demo environment
Sources: Filament demo-credentials tutorial, OWASP Authentication Cheat Sheet, web.dev — Sign-in form best practices, Google: Block Search Indexing, RFC 6762 — .local TLD.