Hermes Wiki
TechResearch/Clerk_Based_Auth/clerk_free_tier_access_control

Restricting who can sign in on Clerk's free (Hobby) tier

Companion to clerk_based_auth.md and clerk_production_setup_walkthrough.md. Both of those assumed Clerk's dashboard Restrictions → Allowlist feature would gate sign-up to just your own account. In practice, allowlisting is a paid-plan feature — not available on Hobby. This doc is the actual working fix for hermes-wiki, reusable for any other project that needs "only specific people" access on the free tier.

The problem

Clerk's OAuth integration (Google/GitHub) proves who someone is — a real, verified identity. It does not by itself decide whether they're allowed in. Without an allowlist, anyone who completes the OAuth flow with any Google/GitHub account gets a valid Clerk session and full access. For a private wiki with business notes and personal research, that's not acceptable — and the dashboard-level fix (Restrictions → Allowlist) isn't available without upgrading.

The fix: authorization in app code instead of Clerk's dashboard

Since the app is fully self-built (Next.js + @clerk/nextjs middleware), the authorization check doesn't need to live in Clerk's dashboard — it can live in middleware.ts instead. Clerk still handles authentication (proving identity); the app now also checks authorization (deciding access) on every request.

1. Add an email claim to the session token

By default, Clerk's session JWT doesn't necessarily expose the user's email in a place middleware can cheaply read without an extra API call. Fix this once in the dashboard (this part is free on Hobby — JWT customization is a core feature, unlike allowlisting):

Configure → Sessions → Customize session token — add:

{
  "email": "{{user.primary_email_address}}"
}

This needs to be set on each Clerk instance (dev and production have independent session token configs, same as every other per-instance setting noted in the production walkthrough).

2. Check the claim in middleware, redirect if not allowlisted

viewer/middleware.ts:

import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";

const isPublicRoute = createRouteMatcher(["/sign-in(.*)", "/unauthorized"]);

const allowedEmails = (process.env.ALLOWED_EMAILS ?? "")
  .split(",")
  .map((email) => email.trim().toLowerCase())
  .filter(Boolean);

export default clerkMiddleware(async (auth, req) => {
  if (isPublicRoute(req)) {
    return;
  }

  await auth.protect();

  const { sessionClaims } = await auth();
  const email = (sessionClaims?.email as string | undefined)?.toLowerCase();

  if (!email || !allowedEmails.includes(email)) {
    return NextResponse.redirect(new URL("/unauthorized", req.url));
  }
});

auth.protect() still runs first — it handles the "not signed in at all" case (redirects to /sign-in). The email check runs after, handling the "signed in, but not you" case (redirects to /unauthorized).

3. Add the /unauthorized page

A minimal page at viewer/app/unauthorized/page.tsx explaining access is denied, with a <SignOutButton> so a visitor (or you, testing with the wrong account) can sign out and try a different one.

4. Configure the allowlist via env var

[email protected] in .env.local (comma-separated for multiple people). Kept out of .env.example's committed defaults, same as the Clerk keys — real values only live in the VPS's .env.local.

Verifying it actually works

Don't just test the happy path (your own email). Explicitly test the rejection path too — sign in with a different Google account and confirm you land on /unauthorized, not on the wiki content. It's easy to ship an allowlist check that silently no-ops (e.g. empty ALLOWED_EMAILS parsing to [] and the !allowedEmails.includes(email) check still evaluating correctly, but a typo'd claim name or a session token that wasn't actually customized would make email always undefined and everyone get bounced — or worse, a bug that makes the check always pass).

When this doesn't apply

This pattern is specific to wiki (private, single/few-user). Courses and Localz are meant to be public sign-up products by design — they don't need this at all, and adding it there would defeat the point of a public product.

Hermes Wiki