A one-person admin: Google sign-in, a sign-in log and direct uploads

7 min read

This is part 5 of a series on how this blog is built. The earlier parts covered the editor, rendering and publishing. This part is about the private side: who is allowed in, and how images get uploaded.

A blog with one author has an unusual security model. There are no roles, no permissions and no sign-up. There is one person who may do everything, and everyone else, who may do nothing.

One email address

I did not want to store a password, so sign-in is Google only, through Better Auth. The interesting part is what happens after Google says who you are. A hook runs before any user is created, and it compares the email with one environment variable:

databaseHooks: {
  user: {
    create: {
      // Single-author blog: only the owner's email may ever create an account.
      before: async (user, ctx) => {
        const allowed = adminEmail();
        if (!allowed || user.email.toLowerCase() !== allowed) {
          await recordLoginAttempt(user, ctx?.headers ?? ctx?.request?.headers, /* provider */);
          throw new APIError("FORBIDDEN", {
            code: NOT_ALLOWED,
            message: "This account is not allowed to sign in.",
          });
        }
        return { data: user };
      },
    },
  },
},

Anyone can press "Continue with Google" on the login page. If the account is not mine, no user row is ever written, so there is nothing left behind to clean up or to exploit later. Note the first half of the condition: if the variable is missing, nobody gets in. A misconfigured deployment fails closed.

[Screenshot to add: The login page with the Google sign-in button]

Three checks, not one

A request to the admin passes through three layers, and it is worth being clear about what each one is for.

The proxy is a courtesy

proxy.ts, which is what Next.js 16 calls middleware, runs before any admin page:

// Optimistic gate only (cookie presence). The real check, including the
// ADMIN_EMAIL match, happens in requireAdmin()/assertAdmin() on the server.
export function proxy(request: NextRequest) {
  if (!getSessionCookie(request)) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
}

export const config = {
  matcher: ["/admin/:path*"],
};

It only checks that a session cookie exists. It does not check that the cookie is valid, and anyone can set a cookie with that name. Its job is to send signed-out visitors to the login page quickly, without loading anything. It is not security.

Pages check the session

Every admin page and layout calls requireAdmin(), which looks up the real session and compares its email again:

/** Returns the session only if it belongs to the configured admin email. */
export async function getAdminSession() {
  const session = await getSession();
  if (!session) return null;
  if (session.user.email.toLowerCase() !== env.ADMIN_EMAIL) return null;
  return session;
}

/** For admin pages/layouts: redirects to the login page. */
export async function requireAdmin() {
  const session = await getAdminSession();
  if (!session) redirect("/login");
  return session;
}

Actions check it again

This is the one that is easy to forget. A server action is a public HTTP endpoint. It can be called directly by anyone who knows how, whether or not they can load the page it belongs to. So every action that changes data starts with its own check:

export async function savePost(raw: SavePostInput): Promise<SavePostResult> {
  await assertAdmin();
  // ...
}

assertAdmin() throws instead of redirecting, because a redirect makes no sense as the answer to a failed save. The upload endpoint does the same thing and returns a 401.

The rule I follow: a page being protected says nothing about the actions on it. Each one protects itself.

A log of rejected sign-ins

The login page is public, so sooner or later someone who is not me will try it. I wanted to see that when it happens. The hook above records every rejected attempt before refusing it:

await db.insert(loginAttempts).values({
  email: identity.email.trim().toLowerCase().slice(0, MAX_FIELD),
  name: clean(identity.name),
  // First hop of x-forwarded-for is the client as seen by the platform's proxy.
  ipAddress: clean(header("x-forwarded-for")?.split(",")[0] ?? header("x-real-ip")),
  userAgent: clean(header("user-agent")),
  country: clean(header("x-vercel-ip-country")),
  city: clean(decode(header("x-vercel-ip-city"))),
  timezone: clean(header("x-vercel-ip-timezone")),
  // ...
});

The location fields come for free: Vercel adds them as headers on every request. The admin has a page listing these attempts.

[Screenshot to add: The sign-in attempts page in the admin]

Two details in that function matter more than the data it collects.

It never throws. The whole thing is wrapped in a try that logs and carries on. A sign-in must be rejected because the email is wrong, not accepted or broken because the logging table had a problem.

Everything except the email is a hint. The email comes from Google. The rest comes from request headers, which the visitor controls. Every value is trimmed and cut to a fixed length before it is stored, and I treat the page as a curiosity rather than evidence.

Uploading images straight to storage

Images live in Cloudflare R2, which is compatible with the S3 API and does not charge for downloads.

The simple way to upload is to send the file to my server and have the server pass it on. On a serverless host that is a poor fit: request bodies are size-limited, and I would be paying for a function to sit there copying bytes. Instead the browser uploads to R2 directly, in two steps.

Step one. The browser asks my server for permission, describing the file:

const presign = await fetch("/api/upload", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ filename: file.name, contentType: file.type, size: file.size }),
});

The server checks the session, checks the type and size, picks the file's name, and returns a presigned URL. That is a normal R2 address with a signature attached, valid for five minutes, allowing an upload of that exact file type to that exact name:

const base = slugify(filename.replace(/\.[^.]+$/, "")) || "file";
const key = `uploads/${now.getUTCFullYear()}/${month}/${randomUUID().slice(0, 8)}-${base}.${ext}`;

const { uploadUrl, publicUrl } = await createPresignedUpload(key, contentType);
await db.insert(media).values({ key, url: publicUrl, filename, contentType, size });

Step two. The browser sends the file to that URL:

const put = await fetch(uploadUrl, {
  method: "PUT",
  headers: { "Content-Type": file.type },
  body: file,
});

My server never sees the file, and the storage credentials never leave the server. The file name is chosen by the server, not the browser, so nobody can overwrite an existing file by picking its name. The random prefix keeps two uploads called screenshot.png apart.

The allowed types and the size limit sit in one small file that both sides import, so the browser can refuse a bad file before asking and the server enforces the same rule:

// Shared by the upload API (server) and the file inputs (browser).
export const ALLOWED_IMAGE_TYPES: Record<string, string> = {
  "image/jpeg": "jpg",
  "image/png": "png",
  "image/gif": "gif",
  "image/webp": "webp",
  "image/avif": "avif",
};

export const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;

[Screenshot to add: The media library with a few uploaded images]

What went wrong along the way

CORS on the bucket. A direct upload is a request from my site to a different domain, so the browser blocks it unless the bucket says it is allowed. The bucket needs a CORS rule that permits PUT from the site's address and allows the Content-Type header. Without it the upload fails in the browser with an error that mentions neither R2 nor permissions, which makes it a confusing one to diagnose. Local development needs http://localhost:3000 in the same rule.

Orphan records. Look again at step one: the media row is written when the URL is issued, before the file exists. If the upload then fails, or I close the tab, there is a row pointing at nothing. I know about this and have left it for now; the media library lets me delete such rows by hand. The proper fix is to write the row only after the browser confirms the upload, or to sweep for rows whose file is missing.

Signed-out is not the same as wrong account. Someone signed in to Google with the wrong account should see a clear message, not be bounced back to the login page with no explanation. The hook throws an error with a specific code, the callback lands on the login page with that code in the address, and the page shows a plain sentence saying the account is not allowed.

Part 6 is the last one: the visual details, and what it took to run all of this on free tiers.

The whole series

  1. Why I built my own blog instead of writing on Medium

  2. Building a Medium-style editor with Tiptap

  3. From editor JSON to fast, crawlable pages

  4. Publishing without a redeploy: drafts, scheduling and ISR

  5. A one-person admin: Google sign-in, a sign-in log and direct uploads (this post)

  6. Design details and shipping on free tiers