Search docs

Jump between documentation pages.

Secure admin panels

Aikido's "How to build a secure admin panel for your SaaS app" lists the recurring mistakes that turn customer-success tooling into a breach: admin endpoints stitched into the same surface as the public app, shared support accounts with no audit trail, single-factor authentication, and no Content-Security-Policy to contain injected JavaScript. Daloy doesn't ship an admin panel, but every primitive you need to follow that checklist is already in the framework. This page maps each rule to the helper that enforces it.

1. Don't mix admin routes into your public app

The first rule is the most important: admin endpoints should not be reachable from the same hostname as the public API, and they should not leak into client-side bundles where attackers can probe them. Daloy gives you two tools that compose into a clean "private API only" posture:

  • internal: trueon a route hides it from the public listener entirely — it is only reachable via app.inject() (server-to-server) or through an adapter that explicitly mounts internal routes on a separate socket / hostname / port.
  • subdomains() lets you mount the admin sub-app on admin.example.com while the public API stays on api.example.com, so a critical issue in the admin code can be taken offline (firewall, DNS, deploy) without affecting the customer-facing app.
ts
import { App, ipRestriction, secureHeaders, bearerAuth } from "@daloyjs/core";

const app = new App({ env: "production" });

// Public surface stays minimal and visible.
app.route({
  method: "GET",
  path: "/health",
  operationId: "health",
  responses: { 200: { description: "ok" } },
  handler: async () => ({ status: 200 as const, body: { ok: true } }),
});

// Admin surface is opt-in only. The internal flag keeps it out of the
// OpenAPI document and out of the public listener entirely.
app.route({
  method: "POST",
  path: "/admin/users/:id/disable",
  operationId: "adminDisableUser",
  internal: true,
  hooks: [
    ipRestriction({ allow: ["10.0.0.0/8", "203.0.113.4/32"] }),
    bearerAuth({
      validate: (token, { state }) => verifyAdminToken(token, state),
      realm: "daloy-admin",
    }),
  ],
  responses: { 204: { description: "ok" } },
  handler: async () => ({ status: 204 as const, body: undefined }),
});

Front the internal listener with a VPN, a Cloudflare Access tunnel, or a private load balancer. The combination of internal: true + ipRestriction() means misconfigured DNS or a routing accident cannot expose the admin surface to the public internet by default.

2. Per-admin accounts with an audit log

Aikido's second rule is to ban shared support@app.io logins so every sensitive change is attributable. Daloy doesn't ship an identity provider — pick one (Auth0, Clerk, Cognito, Keycloak, or your own JWT issuer) and verify per-admin tokens with bearerAuth() or the JWT helpers. The framework gives you the audit-log primitives:

  • requestId() stamps every request with a propagated x-request-id so the same identifier appears in every downstream log line.
  • The built-in loggeremits structured JSON; attach the authenticated admin's subject claim in your hooksso "who did what, when, from where" falls out for free.
  • tracing() ties the same request id into OpenTelemetry spans for long-term retention in your observability stack.
ts
import { jwtVerify, requestId, logger } from "@daloyjs/core";

app.use(requestId());
app.use(logger({ level: "info" }));

const adminAuth = jwtVerify({
  issuer: "https://login.example.com/",
  audience: "daloy-admin",
  // Per-admin tokens carry the admin's subject + email + role claims.
  required: { roles: ["admin"] },
});

app.route({
  method: "POST",
  path: "/admin/feature-flags/:flag",
  operationId: "adminToggleFlag",
  internal: true,
  hooks: [
    ipRestriction({ allow: ["10.0.0.0/8"] }),
    adminAuth,
    {
      afterHandle: (_res, ctx) => {
        // Structured audit record — one line per sensitive change.
        ctx.log.info({
          event: "admin.flag.toggle",
          actor: ctx.state.jwt?.sub,
          actorEmail: ctx.state.jwt?.email,
          flag: ctx.params.flag,
          requestId: ctx.requestId,
        }, "admin toggled feature flag");
      },
    },
  ],
  responses: { 204: { description: "ok" } },
  handler: async () => ({ status: 204 as const, body: undefined }),
});

3. Enforce 2FA (or 3FA) for admin auth

Daloy doesn't implement TOTP / WebAuthn itself — that belongs in your identity provider — but it gives you three layers that compose with whatever 2FA your IdP enforces, so a stolen password alone is not enough:

  • Network factor. ipRestriction() with a tight CIDR allow-list (corporate VPN, Cloudflare WARP egress, office gateway) means a credential leak from outside that range is rejected before authentication even runs.
  • Login-throttle factor. rateLimit({ windowMs, max, groupId: "admin-auth" }) shares one bucket across /admin/login, /admin/otp, and /admin/recovery so password spraying and OTP guessing are both throttled by the same counter.
  • Session factor. session() with cookieOptions: { secure: true, sameSite: "strict" } plus csrf() on every mutating route closes the state-changing-request loophole even if a cookie escapes the admin subdomain.
ts
import { rateLimit, session, csrf } from "@daloyjs/core";

const adminLoginLimit = () =>
  rateLimit({ windowMs: 60_000, max: 5, groupId: "admin-auth" });

app.use(session({
  secret: process.env.ADMIN_SESSION_SECRET!,
  cookieOptions: { secure: true, httpOnly: true, sameSite: "strict" },
}));
app.use(csrf({ strategy: "fetch-metadata" }));

app.route({
  method: "POST",
  path: "/admin/login",
  operationId: "adminLogin",
  internal: true,
  hooks: [adminLoginLimit() /* + your IdP verification */],
  // …
});
app.route({
  method: "POST",
  path: "/admin/otp",
  operationId: "adminOtp",
  internal: true,
  hooks: [adminLoginLimit() /* + TOTP / WebAuthn verification */],
  // …
});

4. Block unknown JavaScript with CSP

Aikido's last rule — and the one that would have prevented the "Apple email injection" case they cite — is a strict Content-Security-Policy on the admin HTML. Daloy's secureHeaders() already emits a CSP, and it can mint a fresh per-request nonce so legitimate inline bootstrap scripts run while any injected <script> from a future XSS is silently dropped by the browser.

ts
import { secureHeaders } from "@daloyjs/core";

app.use(secureHeaders({
  // Strict CSP for the admin surface: only same-origin code, no inline
  // scripts unless they carry the per-request nonce, no objects, no
  // framing, and Trusted Types required for any DOM sink.
  contentSecurityPolicy: {
    directives: {
      "default-src": "'none'",
      "script-src": "'self'",
      "style-src": "'self'",
      "img-src": ["'self'", "data:"],
      "connect-src": "'self'",
      "frame-ancestors": "'none'",
      "base-uri": "'none'",
      "form-action": "'self'",
    },
    nonce: true,
    trustedTypes: { policies: ["default"] },
  },
  // Modern HSTS + tight cross-origin posture for an admin host.
  hsts: { maxAgeSeconds: 31536000, includeSubDomains: true, preload: true },
  crossOriginOpenerPolicy: "same-origin",
  crossOriginResourcePolicy: "same-origin",
  // Route violations to a reporting endpoint so you see attempted XSS in
  // production instead of finding out from a customer.
  reportingEndpoints: { csp: "https://csp.example.com/report" },
  reportTo: "csp",
}));

Pair this with app.cspReportRoute()if you want Daloy to terminate the report endpoint itself (size-capped, with optional body redaction so reported URLs don't leak PII into logs).

Checklist — Aikido rule → Daloy primitive

RuleWhat Daloy gives you
Admin panel is not built into the public appinternal: true routes + app.inject(), optional subdomains() mount on a separate host
Admin reachable only from trusted networksipRestriction({ allow: [...] }) with CIDR support, fails closed when no peer address is available
Per-admin authentication, no shared accountsbearerAuth(), basicAuth(), JWT helpers,session()— each ties a request to an identifiable subject
Action audit logrequestId() + logger structured JSON + tracing() spans
2FA / 3FA: throttle login + OTP + recovery togetherrateLimit({ groupId: "admin-auth" }) shared bucket across all auth routes
State-changing routes can't be cross-site triggeredcsrf() (double-submit or fetch-metadata) + session() with SameSite=Strict; Secure; HttpOnly
Block unknown JavaScript (CSP)secureHeaders({ contentSecurityPolicy: { …, nonce: true, trustedTypes }, hsts, … }) + app.cspReportRoute()
Take admin offline without taking the app offlineSeparate internal listener / subdomain mount — flip a feature flag or firewall rule, leave the public API running

What Daloy intentionally does not do

  • Implement TOTP, WebAuthn, or SSO — use an identity provider. Daloy verifies the resulting bearer tokens / JWTs.
  • Render an admin UI. The framework is API-first; pair it with any admin framework (Refine, AdminJS, Retool, internal Next.js) and point that UI at the internal-only Daloy routes.
  • Decide your network perimeter. ipRestriction() enforces a CIDR list, but you still own the firewall, VPN, or zero-trust access layer that determines which addresses are trustworthy in the first place.