Securing and Logging APIs

Introduction: Why Secure and Log Your API?

Welcome back! You now have a clean service layer and task routes wired up in Remix. The next step is to make those endpoints safe to call and easy to observe. In production, APIs are often exposed to the public internet; without basic protection, anyone (or anything) can hammer your endpoints. Likewise, without logging, it’s hard to diagnose issues, understand usage patterns, or trace bad inputs.

In this lesson, you’ll add API key authentication and request logging to your Task Manager API. You’ll gate every request behind a simple x-api-key header and wrap handlers with a logging utility so you can see what’s happening—method, path, and outcomes—right in your server logs.

Recap: Where We Are Now

You already have:

  • A service layer that centralizes task logic (create, read, update, delete).
  • Remix routes that call the service layer and return standardized JSON using ok and err.
  • Validation for request bodies via validateTaskPayload to keep your data consistent.

These pieces give you correctness and organization. Now you’ll add access control (API key) and visibility (logging), which are essential for production readiness.

API Key Authentication: The Smallest Useful Lock

The requireApiKey function checks every request for a header named x-api-key and compares it to a server-side secret. If the key is missing or incorrect, you return a 401 Unauthorized without touching the service layer. This keeps your business logic clean and prevents unauthorized usage.

app/utils/apiKey.server.ts

import { err } from "~/lib/responses";

const DEFAULT_API_KEY = "dev-task-api-key";

export function requireApiKey(request: Request) {
  const expected = process.env.SECRET_API_KEY ?? DEFAULT_API_KEY;
  if (!expected) return null;

  const provided = request.headers.get("x-api-key");
  if (!provided || provided !== expected) {
    return err("Unauthorized. Provide a valid x-api-key header.", 401);
  }

  return null;
}

export function getExpectedApiKey(): string {
  return process.env.SECRET_API_KEY ?? DEFAULT_API_KEY;
}

Detailed notes

  • Why a header and not a query string: Using x-api-key avoids exposing credentials in URLs that might end up in logs, browser history, or analytics tools. A request header is the safer, conventional place for symmetric API keys.
  • Fail fast at the edge of your route: The function returns early with an err(...) response when the key is invalid. This stops further logic—validation, database calls, or service operations—from executing, saving CPU and reducing attack surface.
  • Environment awareness: The function prefers process.env.SECRET_API_KEY but falls back to a default for development. This design lets you spin up locally without friction while keeping real secrets secure in production.

The !expected guard allows API key enforcement to be intentionally disabled by configuration (for example, setting SECRET_API_KEY or DEFAULT_API_KEY to an empty string in local demos or tests). In normal development and production setups this branch will not trigger, but it keeps the authentication layer flexible without changing route code.

Keeping Secrets in Environment Variables

Put secrets in .env, not in source code. This allows you to rotate keys without changing code and prevents accidental leaks if your repository becomes visible.

.env

SECRET_API_KEY=dev-task-api-key
  • Operational flexibility: Environment variables let you use different keys per environment—local, staging, or production—without changing the code. You can rotate keys simply by updating configuration and restarting the server.
  • Security posture: Keeping secrets out of version control prevents accidental exposure in pull requests, code reviews, or screenshots.
  • Safe defaults vs. strict production: A DEFAULT_API_KEY is convenient for local work, but in production you should ensure SECRET_API_KEY is always set—and consider failing startup if it isn’t—to avoid running with weak defaults.

Securing the Collection Route (`/api/tasks`)

The collection route now enforces the API key for both GET and POST requests. It validates query parameters and request bodies, and always returns standardized responses. All of this is wrapped in withLogging, so you can see start and finish logs for each request.

app/routes/api.tasks.tsx

import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";

import { ok, err } from "~/lib/responses";
import { createTask, filterTasksByCompletion, getAllTasks } from "~/lib/services/taskService";
import { validateTaskPayload } from "~/lib/taskValidation";
import { requireApiKey } from "~/utils/apiKey.server";
import { withLogging } from "~/utils/withLogging.server";

export async function loader({ request }: LoaderFunctionArgs) {
  return withLogging("GET /api/tasks", async () => {
    const unauthorized = requireApiKey(request);
    if (unauthorized) return unauthorized;

    const url = new URL(request.url);
    const completed = url.searchParams.get("completed");

    let result = getAllTasks();
    if (completed !== null) {
      if (completed !== "true" && completed !== "false") {
        return err("Invalid 'completed' filter. Use true or false.", 400);
      }
      result = filterTasksByCompletion(completed === "true");
    }

    return ok(result, 200, {
      total: result.length,
      filters: { completed: completed ?? null }
    });
  });
}

export async function action({ request }: ActionFunctionArgs) {
  return withLogging(`${request.method} /api/tasks`, async () => {
    const unauthorized = requireApiKey(request);
    if (unauthorized) return unauthorized;

    if (request.method !== "POST") {
      return err("Method Not Allowed", 405);
    }

    try {
      const body = await request.json();
      const errors = validateTaskPayload(body, { requireAll: true });
      if (errors.length > 0) {
        return err(errors, 400);
      }

      const created = createTask({
        title: body.title,
        description: body.description,
        completed: body.completed,
        dueDate: body.dueDate
      });

      return ok(created, 201);
    } catch {
      return err("Invalid JSON body", 400);
    }
  });
}
  • Authentication comes first: Both handlers call requireApiKey() at the top, blocking unauthorized requests before any work begins. This ensures protection and reduces wasted resources.
  • Controlled filtering: The completed parameter is validated strictly as "true" or "false", preventing unexpected inputs like numbers or empty values.
  • Uniform responses with metadata: Using ok(..., 200, { total, filters }) ensures every response has a consistent shape, providing helpful context such as the total number of results and applied filters.

Securing the Item Route (`/api/tasks/:id`)

The item route follows the same pattern: authenticate early, validate IDs and payloads, and delegate to the service layer. It supports GET, PUT, PATCH, and DELETE.

app/routes/api.tasks.$id.tsx

import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";

import { ok, err } from "~/lib/responses";
import {
  deleteTask,
  getTaskById,
  patchTask,
  replaceTask
} from "~/lib/services/taskService";
import { validateTaskPayload } from "~/lib/taskValidation";
import { requireApiKey } from "~/utils/apiKey.server";
import { withLogging } from "~/utils/withLogging.server";

export async function loader({ request, params }: LoaderFunctionArgs) {
  return withLogging("GET /api/tasks/:id", async () => {
    const unauthorized = requireApiKey(request);
    if (unauthorized) return unauthorized;

    const id = Number(params.id);
    if (!Number.isInteger(id)) {
      return err("Invalid task id", 400);
    }

    const task = getTaskById(id);
    if (!task) {
      return err("Task not found", 404);
    }

    return ok(task);
  });
}

export async function action({ request, params }: ActionFunctionArgs) {
  const id = Number(params.id);

  return withLogging(`${request.method} /api/tasks/:id`, async () => {
    const unauthorized = requireApiKey(request);
    if (unauthorized) return unauthorized;

    if (!Number.isInteger(id)) {
      return err("Invalid task id", 400);
    }

    if (request.method === "DELETE") {
      const deleted = deleteTask(id);
      return deleted ? ok(null, 204) : err("Task not found", 404);
    }

    try {
      const body = await request.json();

      if (request.method === "PUT") {
        const errors = validateTaskPayload(body, { requireAll: true });
        if (errors.length > 0) {
          return err(errors, 400);
        }

        const updated = replaceTask(id, {
          title: body.title,
          description: body.description,
          completed: body.completed,
          dueDate: body.dueDate
        });

        return updated ? ok(updated) : err("Task not found", 404);
      }

      if (request.method === "PATCH") {
        const errors = validateTaskPayload(body, { requireAll: false });
        if (errors.length > 0) {
          return err(errors, 400);
        }

        const updated = patchTask(id, {
          title: body.title,
          description: body.description,
          completed: body.completed,
          dueDate: body.dueDate
        });

        return updated ? ok(updated) : err("Task not found", 404);
      }

      return err("Method Not Allowed", 405);
    } catch {
      return err("Invalid JSON body", 400);
    }
  });
}
  • ID validation before work: By converting and validating params.id immediately, the handler prevents non-numeric or malformed IDs from reaching the service layer.
  • Method branching with clear responsibilities: Each HTTP method (DELETE, PUT, PATCH) performs exactly one role, validates data differently, and returns the appropriate status code.
  • Consistent error handling: Missing or invalid resources always produce err("Task not found", 404), helping clients handle errors predictably.

Logging: Making Requests Observable

Each route is wrapped with withLogging, which prints structured logs before and after each request runs. It also captures and reports errors.

Example usage (already applied above):

return withLogging("GET /api/tasks", async () => {
  // ... handler logic
});
  • Start/finish visibility: Logging at the beginning and end of each request helps trace concurrent activity. It’s easy to match start and end pairs by their shared label.
  • Error surfacing: If a handler throws an exception, withLogging catches and logs it, preventing silent failures.
  • Extensibility: You can extend logging to include timing (execution duration), request IDs, or structured metadata to improve observability in production.

Testing: Try It with curl

You can now test authentication and logging by calling your routes directly.

Authenticate every call by passing x-api-key:

curl -H "x-api-key: dev-task-api-key" \
  http://localhost:3000/api/tasks
curl -H "x-api-key: dev-task-api-key" \
  "http://localhost:3000/api/tasks?completed=true"
curl -X POST -H "Content-Type: application/json" \
  -H "x-api-key: dev-task-api-key" \
  -d '{"title":"Draft syllabus","description":"Unit outline","completed":false}' \
  http://localhost:3000/api/tasks
curl http://localhost:3000/api/tasks
# => { "error": "Unauthorized. Provide a valid x-api-key header." }

Security & Logging Guidelines (Practical Advice)

  • Don’t log secrets: Never print API keys or Authorization headers to logs. Mask or filter sensitive headers before writing logs.
  • Rotate keys periodically: Environment-based secrets make key rotation easy—just update the value and restart.
  • Prefer least privilege: Even a single API key can evolve into a scoped or rate-limited system later. This pattern is a foundation for that.
  • Keep logs actionable: Focus on essentials—method, path, outcome, duration. Too much noise obscures important events.

Summary & What’s Next

You secured your Task Manager API by enforcing an x-api-key on every request and wrapped routes with withLogging to make behavior observable. Your routes now:

  • Authenticate early and reject unauthorized calls immediately.
  • Log consistently, giving visibility into every request.
  • Return uniform responses, simplifying client behavior.

Next, you’ll strengthen robustness further by expanding validation coverage and considering rate limiting or request IDs for deeper observability—moving toward a truly production-grade backend.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal