Public Authentication
Dune's public auth system lets site visitors register and log in — completely separate from the admin panel. Login methods: OAuth (GitHub, Google, Discord), magic link (passwordless email), and external JWT (Clerk, Auth0, etc.).
Configuration
# site.yaml
auth:
mode: "dune" # "dune" | "external-jwt"
sessionLifetime: 2592000 # Session TTL in seconds (default: 30 days)
providers:
github:
clientId: "$GITHUB_CLIENT_ID"
clientSecret: "$GITHUB_CLIENT_SECRET"
google:
clientId: "$GOOGLE_CLIENT_ID"
clientSecret: "$GOOGLE_CLIENT_SECRET"
discord:
clientId: "$DISCORD_CLIENT_ID"
clientSecret: "$DISCORD_CLIENT_SECRET"
magicLink:
enabled: true
Only configure the providers you need. Each OAuth provider requires a registered OAuth app pointing its callback to {site.url}/auth/{provider}/callback.
Registered routes
| Method | Route | Description |
|---|---|---|
GET |
/auth/login |
Default login page (renders LoginForm or theme's auth/login.tsx) |
POST |
/auth/logout |
Destroy session cookie and redirect to / |
GET |
/auth/me |
Return current SiteUser as JSON, or 401 if not logged in |
GET |
/auth/github |
Start GitHub OAuth flow |
GET |
/auth/github/callback |
GitHub OAuth callback |
GET |
/auth/google |
Start Google OAuth flow |
GET |
/auth/google/callback |
Google OAuth callback |
GET |
/auth/discord |
Start Discord OAuth flow |
GET |
/auth/discord/callback |
Discord OAuth callback |
POST |
/auth/magic-link/send |
Send a magic link to email (form param) |
GET |
/auth/magic-link/verify |
Verify magic link token and create session |
Routes for unconfigured providers return 404.
OAuth login
Each OAuth provider follows the standard authorization code flow:
- User visits
/auth/github→ redirected to GitHub withstateparameter - GitHub redirects back to
/auth/github/callback?code=...&state=... - Dune exchanges the code for an access token, fetches the user profile
- Dune upserts a
SiteUserrecord (creates on first login, updates on subsequent) - Session cookie
dune-site-sessionis set; user is redirected to?next=or/
OAuth app setup
| Provider | Callback URL |
|---|---|
| GitHub | {site.url}/auth/github/callback |
{site.url}/auth/google/callback |
|
| Discord | {site.url}/auth/discord/callback |
Magic link
The magic link flow is passwordless:
- User enters their email at
/auth/login(or any form POSTing to/auth/magic-link/send) - Dune generates a token (HMAC-SHA256 signed, 15-minute TTL), sends an email with a link to
/auth/magic-link/verify?token=... - User clicks the link → Dune verifies the token, upserts the user, sets the session cookie
Magic link requires the email module to be configured — without it, the link is logged to stdout (development only).
External JWT mode
Use mode: "external-jwt" to delegate authentication entirely to an external provider (Clerk, Auth0, Supabase, etc.). Dune validates the Bearer token on each request and maps JWT claims to a SiteUser.
auth:
mode: "external-jwt"
jwt:
jwksUrl: "https://your-tenant.clerk.accounts.dev/.well-known/jwks.json"
userIdClaim: "sub" # default
emailClaim: "email" # default
rolesClaim: "roles" # default — string or string[]
For HS256 shared-secret tokens:
auth:
mode: "external-jwt"
jwt:
secret: "$JWT_SECRET"
In external-JWT mode, there are no session cookies and no /auth/* login routes — your external provider handles the login UI. Clients pass tokens as Authorization: Bearer {token} headers. The auth middleware injects a synthetic SiteUser from the validated claims.
SiteUser
Every logged-in visitor is represented as a SiteUser:
interface SiteUser {
id: string; // Internal UUID
email: string; // Primary identifier
name?: string; // Display name (from OAuth profile)
avatarUrl?: string; // Avatar URL (from OAuth profile)
roles: string[]; // Assigned roles (e.g. ["member"])
provider: string; // "github" | "google" | "discord" | "magic" | "jwt"
providerId?: string; // Provider's user ID (for OAuth)
createdAt: number; // Unix timestamp (ms)
}
Users are stored as flat YAML files in data/site-users/ (controlled by admin.dataDir). The directory should be committed to version control — site user records are site data, not ephemeral runtime state.
An email-based index in data/site-users/by-email/ allows O(1) lookups by email address for login flows.
Accessing the current user
In route handlers / Fresh middleware:
const siteUser = ctx.state.siteUser as SiteUser | null;
In TSX page handlers — the middleware injects a JSON-encoded SiteUser in the x-dune-site-user request header, which Dune automatically parses and makes available as page.siteUser in TemplateProps.
Via the API:
GET /auth/me
→ 200 { id, email, name, avatarUrl, roles, provider, createdAt }
401 if not logged in
Login page template
To customise the login page, add templates/auth/login.tsx to your theme:
import type { TemplateProps } from "@dune/core";
import { LoginForm } from "@dune/core/ui";
export default function AuthLogin({ site, Layout, ...props }: TemplateProps) {
return (
<Layout {...props} site={site} pageTitle="Log in">
<h1>Log in to {site.title}</h1>
<LoginForm providers={["github", "google", "magic"]} />
</Layout>
);
}
If no auth/login.tsx exists in the theme, Dune renders a minimal built-in login page.