Skip to content

Auth

There are several ways to add authentication to your Val Town app. Pick the approach that fits your needs, then use the provided prompt to have Townie or your LLM implement it.

ApproachBest forUsers need
Sign in with Val TownApps for the Val Town communityA Val Town account
HTTP Basic AuthQuick password protectionA shared password
Cookie-based login pagePersonal projects, admin dashboardsA shared password
Magic link authMulti-user apps with email loginAn email address
Third-party providersProduction apps, social login, SSODepends on provider

The std/oauth middleware lets users sign in with their Val Town account. It handles the full OAuth 2.0 flow, stores sessions in encrypted cookies (no database needed), and gives you access to the user’s Val Town profile.

The middleware automatically handles /auth/login, /auth/callback, and /auth/logout routes. Sessions last 30 days.

Reference implementation:

import { Hono } from "npm:hono";
import {
getOAuthUserData,
oauthMiddleware,
} from "https://esm.town/v/std/oauth/middleware.ts";
const app = new Hono();
app.get("/", async (c) => {
const session = await getOAuthUserData(c.req.raw);
if (session?.user) {
return c.html(
`<p>Logged in as ${session.user.username}</p>
<a href="/auth/logout">Log out</a>`,
);
}
return c.html(`<a href="/auth/login">Log in with Val Town</a>`);
});
export default oauthMiddleware(app.fetch);

The simplest way to password-protect your val. The browser shows a built-in login prompt — no UI to build.

Store your credentials in environment variables (BASIC_AUTH_USER and BASIC_AUTH_PASS), then check the Authorization header:

export default async function (req: Request): Promise<Response> {
const auth = req.headers.get("Authorization");
if (auth) {
const [, encoded] = auth.split(" ");
const decoded = atob(encoded);
const [user, pass] = decoded.split(":");
if (
user === Deno.env.get("BASIC_AUTH_USER") &&
pass === Deno.env.get("BASIC_AUTH_PASS")
) {
return new Response("Welcome! You're authenticated.");
}
}
return new Response("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="Protected"' },
});
}

If you want a custom login form instead of the browser’s basic auth popup, you can use a cookie to persist the session. Good for personal projects and admin dashboards.

Store your password in an environment variable (APP_PASSWORD). Reference implementation:

const PASSWORD = Deno.env.get("APP_PASSWORD");
const COOKIE_NAME = "session";
export default async function (req: Request): Promise<Response> {
const url = new URL(req.url);
const cookies = parseCookies(req.headers.get("Cookie") || "");
// Handle login form submission
if (url.pathname === "/login" && req.method === "POST") {
const form = await req.formData();
if (form.get("password") === PASSWORD) {
return new Response(null, {
status: 302,
headers: {
Location: "/",
"Set-Cookie": `${COOKIE_NAME}=${PASSWORD}; Path=/; HttpOnly; SameSite=Lax`,
},
});
}
return loginPage("Wrong password.");
}
// Handle logout
if (url.pathname === "/logout") {
return new Response(null, {
status: 302,
headers: {
Location: "/login",
"Set-Cookie": `${COOKIE_NAME}=; Path=/; Max-Age=0`,
},
});
}
// Show login page if not authenticated
if (cookies[COOKIE_NAME] !== PASSWORD) {
return loginPage();
}
// Authenticated — serve your app
return new Response(
"Welcome! You're logged in. <a href='/logout'>Log out</a>",
{
headers: { "Content-Type": "text/html" },
},
);
}
function loginPage(error?: string) {
return new Response(
`<form method="POST" action="/login">
${error ? `<p style="color:red">${error}</p>` : ""}
<input type="password" name="password" placeholder="Password" />
<button type="submit">Log in</button>
</form>`,
{ headers: { "Content-Type": "text/html" }, status: 401 },
);
}
function parseCookies(cookie: string): Record<string, string> {
return Object.fromEntries(
cookie.split(";").map((c) =>
c
.trim()
.split("=")
.map((s) => s.trim()),
),
);
}

For real multi-user auth where each user signs in with their email, remix the Magic Link Starter template. It uses Lucia for session management, Val Town SQLite for storing users and sessions, and Val Town Email for sending magic links.

The flow:

  1. User enters their email on the login page
  2. Your val generates a token and emails them a magic link
  3. User clicks the link, the token is verified, and a session cookie is set
  4. Sessions last 30 days with automatic token rotation

You can also use any third-party auth provider by connecting to their APIs from your val. Tell your LLM which provider you want to use and it can integrate it.

  • Better Auth — Comprehensive TypeScript auth framework with built-in support for email/password, OAuth, two-factor, and more
  • Auth.js — Framework-agnostic auth (evolved from NextAuth.js) with 80+ OAuth providers
  • Lucia — Lightweight, session-based auth library that works with any database
  • Arctic — Minimal OAuth 2.0 client library with built-in providers (Google, GitHub, Discord, etc.) — pairs well with Lucia
  • Oslo — Low-level utilities for sessions, tokens, cookies, and crypto from the Lucia team
  • Clerk — Drop-in UI components and APIs for sign-up, sign-in, and user management
  • Kinde — Auth, feature flags, and billing in one platform
  • WorkOS — Enterprise SSO and directory sync via API
  • Supabase Auth — Auth bundled with Supabase’s database, works via their JS client
  • Auth0 — Flexible identity platform with social and enterprise login
  • Firebase Auth — Google’s auth service with email, phone, and social sign-in

These older Val Town community libraries still work but are no longer actively recommended. Use std/oauth or the Magic Link Starter instead.

  • stevekrouse/lastlogin — Auth via LastLogin.io (email, Google, GitHub) with no API keys. Not security-audited; best for demos and prototypes.
  • stevekrouse/lucia_middleware — Username/password auth middleware using Lucia and Val Town SQLite. Includes built-in signup/login pages but limited customization.