Back home

Stealing from Google

3 minute read

Modern frameworks like Next.js and Astro come with their own <Image> component. It’s great — you get optimizations, fewer layout shifts, and better performance for free.

But there’s a catch: anyone can abuse your app to optimize their own images, which costs you compute.

That’s why these frameworks require you to explicitly allowlist remote domains.

In Next.js, that looks like:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "lh3.googleusercontent.com",
      },
      {
        protocol: "https",
        hostname: "images.marblecms.com",
      },
    ],
  },
};

export default nextConfig;

And in Astro:

export default defineConfig({
  image: {
    domains: ["images.marblecms.com", "avatars.githubusercontent.com"],
  },
});

So to display a user’s avatar from Google or GitHub, you’d normally need to allowlist their domains too.

But thats the part I didn’t like;

it felt wrong to ask users to trust Google and GitHub’s image servers, when they could simply just allow marble and be done.

The Idea

Instead of making users allow external domains, why not just take the avatar Google or GitHub gives me, upload it to my own bucket, and serve it from there?

That way, users only trust one domain: mine.

My Solution

I’m using Better Auth for authentication. which has a concept of hooks, which let you run code before or after certain events (like when a user is created).

That’s the perfect place to swipe the avatar.

Here’s the core of it — a Next.js server action that:

  1. Verifies the image is indeed from Google or Github

  2. Fetches the users image from the oAuth provider

  3. Uploads it to Cloudflare r2 (which serves images from our custom domain).

  4. Updates the users profile in the database with our the url from cloudflare.

"use server";

import { PutObjectCommand } from "@aws-sdk/client-s3";
import { db } from "@marble/db";
import type { User } from "better-auth";
import { nanoid } from "nanoid";
import { isAllowedAvatarUrl } from "@/lib/constants";
import { R2_BUCKET_NAME, R2_PUBLIC_URL, r2 } from "@/lib/r2";

export async function storeUserImageAction(user: User) {
  if (!user.image) {
    return;
  }

  try {
    if (!isAllowedAvatarUrl(user.image)) {
      console.warn(`Avatar URL not from allowed host: ${user.image}`);
      return;
    }
    const response = await fetch(user.image);
    if (!response.ok) {
      throw new Error(`Failed to fetch image: ${response.statusText}`);
    }

    const contentType = response.headers.get("content-type") || "image/png";

    const arrayBuffer = await response.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);

    const extension = contentType.split("/")[1];
    const key = `avatars/${nanoid()}.${extension}`;

    await r2.send(
      new PutObjectCommand({
        Bucket: R2_BUCKET_NAME,
        Key: key,
        Body: buffer,
        ContentType: contentType,
        ContentLength: buffer.length,
      })
    );

    const avatarUrl = `${R2_PUBLIC_URL}/${key}`;

    await db.user.update({
      where: {
        id: user.id,
      },
      data: {
        image: avatarUrl,
      },
    });

    return { avatarUrl };
  } catch (error) {
    console.error("Failed to store user avatar:", error);
  }
}

So when a user signs up with Google or GitHub, their avatar gets downloaded, re-uploaded to R2, and served from my own domain.

The Result

  • Users only need to allow images.marblecms.com in their configs.

  • My branding is consistent (all images come from my domain).

And that’s how I swiped avatars from Google.

If you’re building an app with OAuth logins, try it — it's quite fun.