Have you seen the movie Bubble Boy?

It is about a boy who spent most of his life inside a sterilized dome because his immune system could not handle the outside world. That is honestly how the corporate development world has felt to me for the past decade.

Most of my work has been around internal tools. Everything is usually protected before I even touch the code. Development happens inside a VPN. Deployment goes into the same private network. Authentication is usually already handled through company standards, SSO, or Active Directory.

That was my normal setup for years.

Then I started working on a small community tool for power outage notifications while also deploying my public website. That was when I got reminded that the public internet is very different.

The moment my site went live, I saw a spike in API usage on my contact form. I checked the logs and saw bots repeatedly hitting the endpoint. The form was harmless, but the bots did not care. They found a public endpoint, so they started poking it.

The annoying part is that the API was deployed on a free hosting service with limited monthly instance hours. Every useless bot request was burning through resources I did not want to waste.

That was the wake-up call.

In cybersecurity, these are usually called threat actors, bad actors, or, as we would say, kontrabidas. It does not always mean some movie-style hacker is personally targeting you. Sometimes it is just crawlers, bots, scanners, spam scripts, or automated tools looking for weak public endpoints.

The lesson is simple: once your app is public, assume it will be touched by traffic you did not invite.

What I changed after seeing the bot traffic

I did not try to solve everything with one magic fix. I added layers.

The goal was simple: do not let the browser hit my API directly, do not expose private headers, and do not let random traffic burn through my limited hosting resources.

The setup now looks like this:

Browser

Cloudflare Turnstile

Cloudflare Worker

ASP.NET Core API

Rate limiting + header check

Layer 1: Put Cloudflare in front

Since my domain is already registered through Cloudflare, I used it as the first layer in front of the site.

Cloudflare helps filter unwanted traffic before it reaches the app. It is not a silver bullet, but it gives you a better starting point than exposing everything directly to the internet.

This is the first lesson I learned:

Do not wait until your small project becomes big before thinking about basic protection.

Even a simple contact form can be abused.

Layer 2: Add Turnstile to the contact form

Next, I added Cloudflare Turnstile to the contact form.

Turnstile is Cloudflare’s captcha alternative. The goal is to verify that the request is more likely coming from a real person and not just a script hammering the endpoint.

Example form:

<form id="contact-form">
  <input name="name" placeholder="Name" required />
  <input name="email" type="email" placeholder="Email" required />
  <textarea name="message" placeholder="Message" required></textarea>

  <div
    class="cf-turnstile"
    data-sitekey="YOUR_TURNSTILE_SITE_KEY">
  </div>

  <button type="submit">Send</button>
</form>

<script
  src="https://challenges.cloudflare.com/turnstile/v0/api.js"
  async
  defer>
</script>

The important part here is knowing which value is safe to expose.

The Turnstile site key can be used in the frontend. The secret key should never be placed in the browser. That belongs in your backend or in a Cloudflare Worker secret.

Layer 3: Send the form to a Cloudflare Worker

Before this change, the browser was calling my API directly.

That meant the API endpoint was visible from the frontend. If I added a private header directly in JavaScript, that header would also be visible to anyone inspecting the browser request.

That is not protection.

So I changed the flow.

The browser now sends the form request to a Cloudflare Worker. The Worker validates the Turnstile token, adds the private internal header, and forwards the request to my API.

Frontend example:

document.getElementById("contact-form").addEventListener("submit", async (event) => {
  event.preventDefault();

  const form = event.target;
  const formData = new FormData(form);

  const payload = {
    name: formData.get("name"),
    email: formData.get("email"),
    message: formData.get("message"),
    turnstileToken: formData.get("cf-turnstile-response")
  };

  const response = await fetch("/api/contact", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify(payload)
  });

  if (!response.ok) {
    alert("Something went wrong. Please try again.");
    return;
  }

  alert("Message sent.");
  form.reset();
});

In my setup, /api/contact points to the Worker route, not directly to my ASP.NET Core API.

That small detail matters.

The browser only knows about the public route. The Worker knows about the real API URL and the private header.

Layer 4: Validate Turnstile inside the Worker

The Worker receives the form data and checks the Turnstile token using Cloudflare’s verification endpoint.

Example Worker:

export default {
  async fetch(request, env) {
    if (request.method !== "POST") {
      return new Response("Method not allowed", { status: 405 });
    }

    let body;

    try {
      body = await request.json();
    } catch {
      return new Response("Invalid request body", { status: 400 });
    }

    const turnstileResponse = await fetch(
      "https://challenges.cloudflare.com/turnstile/v0/siteverify",
      {
        method: "POST",
        body: new URLSearchParams({
          secret: env.TURNSTILE_SECRET_KEY,
          response: body.turnstileToken
        })
      }
    );

    const turnstileResult = await turnstileResponse.json();

    if (!turnstileResult.success) {
      return new Response("Invalid verification", { status: 403 });
    }

    const apiResponse = await fetch(env.CONTACT_API_URL, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-Internal-Contact-Key": env.INTERNAL_CONTACT_KEY
      },
      body: JSON.stringify({
        name: body.name,
        email: body.email,
        message: body.message
      })
    });

    return new Response(await apiResponse.text(), {
      status: apiResponse.status
    });
  }
};

The secret values are stored in the Cloudflare Worker environment:

TURNSTILE_SECRET_KEY
CONTACT_API_URL
INTERNAL_CONTACT_KEY

These values should not be hardcoded in frontend JavaScript.

The Worker acts as the middle layer. It owns the secrets. The browser does not.

Layer 5: Check the private header inside the API

The API still needs its own guard.

Even though the Worker is already checking Turnstile, I still added a header check inside the ASP.NET Core API. This makes sure the contact endpoint only accepts requests that include the internal key.

Example middleware:

app.Use(async (context, next) =>
{
    if (context.Request.Path.StartsWithSegments("/contact"))
    {
        var expectedKey = builder.Configuration["ContactForm:InternalKey"];
        var actualKey = context.Request.Headers["X-Internal-Contact-Key"].ToString();

        if (string.IsNullOrWhiteSpace(actualKey) || actualKey != expectedKey)
        {
            context.Response.StatusCode = StatusCodes.Status403Forbidden;
            await context.Response.WriteAsync("Forbidden");
            return;
        }
    }

    await next();
});

This is not a replacement for real authentication.

For my case, this endpoint is only for a contact form, so the goal is not user login. The goal is to stop random clients from casually calling the API directly.

Layer 6: Add rate limiting

The next layer is rate limiting.

Even valid-looking requests should not be unlimited. A contact form does not need hundreds of submissions per minute from the same source.

Example ASP.NET Core rate limiter:

using System.Threading.RateLimiting;

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

    options.AddFixedWindowLimiter("contact-form", limiter =>
    {
        limiter.PermitLimit = 5;
        limiter.Window = TimeSpan.FromMinutes(1);
        limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        limiter.QueueLimit = 0;
    });
});

app.UseRateLimiter();

app.MapPost("/contact", async (ContactRequest request) =>
{
    // Save message or send email here.
    return Results.Ok();
})
.RequireRateLimiting("contact-form");

This means the endpoint has a limit. If too many requests come in within a short window, the API rejects them with 429 Too Many Requests.

Again, not perfect. But much better than leaving the endpoint wide open.

What this setup does not solve

This setup does not make the app unhackable.

That is not the point.

The point is to stop the most obvious abuse and reduce wasted resources.

There are still things to improve:

  • Header checks are not real authentication.
  • Rate limits need to be tuned based on real traffic.
  • Logs still need to be checked.
  • Bad requests should be monitored.
  • Public endpoints should always be treated carefully.
  • Free hosting limits can disappear quickly if bots keep hitting your app.

But even these basic layers already make a big difference.

Before, the browser could call the API directly.

Now, the request has to pass through Turnstile, then the Worker, then the API header check, then the rate limiter.

That is a much better place to be.

The real lesson

The internet does not care that your app is harmless.

It does not care that it is just a personal website.

It does not care that it is a small community tool.

If it has a public endpoint, something will eventually hit it.

That was the biggest mindset shift for me. In corporate development, a lot of the security structure is already there. VPN, SSO, internal networks, access rules, deployment standards, monitoring, and infrastructure teams are usually part of the environment.

When you deploy solo, you are the environment.

You are the frontend dev, backend dev, database admin, DevOps person, and security person. Even for a small project, you need to think about abuse, spam, logs, rate limits, secrets, and cost.

That does not mean you need to over-engineer everything.

But you cannot ignore the basics either.

About the AI part

Before I wrap this up, I want to address the AI elephant in the room.

Yes, I use AI.

I used it to proofread this article, clean up some wording, organize a few thoughts, and sanity-check parts of the implementation. I also use it in my day-to-day development workflow when I need another angle on a problem.

But I do not see AI as some magic button that replaces the work.

It did not deploy the site for me. It did not check my logs. It did not feel the panic when I saw bots eating through my free hosting hours. It did not decide what trade-offs made sense for my small setup.

Those parts still needed experience, judgment, trial and error, and a bit of stubbornness.

To me, AI is like a rice cooker.

A rice cooker is useful. It saves time, handles the heat, and makes the process easier. But it still does not know how your family likes their rice. It does not know if you want it a little softer, a little drier, or just right for the ulam on the table.

It will not decide to add pandan, garlic, or whatever small thing makes it feel like home.

The tool can help with the process, but the taste still comes from the person using it.

That is how I see AI.

It can help me write cleaner. It can help me think faster. It can explain things when I am stuck. But it still needs a person behind it who cares about the result.

Behind every small project, there is still a human trying to learn, fix mistakes, make something useful, and hopefully help even a few people along the way.

So yes, AI helped me polish some corners.

But the scratches, the lessons, the late-night debugging, and the reason this project exists in the first place are still mine.