Heath SchweitzerHeath Schweitzer
← All posts

Building a Token-Gated API: What I Built and Why

April 25, 2026|Heath Schweitzer|4 min read|15 views|Last Updated June 14, 2026

Technology
Diagram of a token-gated REST API flow from bearer token access to CRUD endpoints and a published post

One of the things I built recently for heathschweitzer.com is a token-gated REST API for managing blog posts. The use case: I wanted to be able to create and publish posts programmatically — pipe content from an AI assistant directly into my CMS without going through the browser UI. This post was created that way.

It's a simple feature with a few interesting technical decisions worth walking through.

Why a Custom API Instead of the Existing Admin

The site already has an admin UI with full post management. But the admin is gated behind session authentication — NextAuth cookies, browser-based login. That works for humans and browsers but not for programmatic access from a curl command or an AI agent running in a terminal.

The options were to either add an API key mechanism to the existing admin routes or build a separate API surface designed for programmatic access. I chose the latter, keeping the concerns separate: the admin routes assume a browser session, the API routes assume a Bearer token.

Token-Gated Authentication with timingSafeEqual

The authentication is simple: every request to /api/v1/posts must include an Authorization: Bearer <token> header. The server compares the provided token against an API_TOKEN environment variable.

The comparison uses Node's timingSafeEqual from the crypto module rather than a plain string comparison:

import { timingSafeEqual } from "crypto";

function authenticate(req: Request): boolean {
  const authHeader = req.headers.get("authorization");
  if (!authHeader?.startsWith("Bearer ")) return false;
  const token = authHeader.slice(7);
  const expected = process.env.API_TOKEN ?? "";
  if (!token || !expected) return false;
  const tokenBuffer = Buffer.from(token);
  const expectedBuffer = Buffer.from(expected);
  if (tokenBuffer.length !== expectedBuffer.length) return false;
  return timingSafeEqual(tokenBuffer, expectedBuffer);
}

The reason for timingSafeEqual is timing attack resistance. A naive string comparison (token === expected) exits early when it finds a mismatched character, which means the comparison takes slightly longer for strings that share a common prefix. An attacker making thousands of requests could measure these timing differences and infer characters of the correct token one by one.

timingSafeEqual always runs in constant time regardless of where the mismatch occurs, eliminating the timing signal. For an API token protecting write access to a production database, this is worth the extra few lines.

The API Surface

The API supports full CRUD for posts:

GET    /api/v1/posts              — list posts with filtering and pagination
POST   /api/v1/posts              — create a new post
GET    /api/v1/posts/:id          — fetch a single post (by id or slug)
PATCH  /api/v1/posts/:id          — update a post
DELETE /api/v1/posts/:id          — delete a post
POST   /api/v1/posts/:id/publish  — convenience endpoint to publish a draft
GET    /api/v1                    — API discovery (no auth required)

The discovery endpoint at /api/v1 returns the available endpoints without authentication — useful for documentation and for agents that need to know the API surface.

A few design choices worth noting:

Virtual SCHEDULED status. The database schema only has DRAFT, PUBLISHED, and ARCHIVED statuses. Scheduled posts are PUBLISHED with a future publishedAt date. The API accepts SCHEDULED as a status in POST and PATCH requests and translates it to PUBLISHED + future date, and the GET endpoint accepts ?status=SCHEDULED as a filter. This keeps the API semantics intuitive while the database schema stays simple.

Author resolution. POST requests don't specify an author — the API looks up the first admin user in the database. On a single-author blog this is the right default. A multi-author setup would need a different approach.

Category and tag creation. Rather than requiring pre-existing category and tag IDs, the API accepts categoryNames and tagNames arrays and creates them via upsert if they don't exist. This makes it possible to create a fully formed post in a single API call without needing to first query for or create categories separately.

Using It in Practice

The workflow I've settled on for AI-assisted content creation:

  1. Write or generate the post content in Markdown
  2. Remove the H1 title (since the title field handles that — it renders as the page's h1)
  3. Push it via curl to the production API as a draft
  4. Review and edit in the admin UI
  5. Schedule or publish

The curl command reads the markdown file and uses jq -Rs . to encode it as a properly escaped JSON string:

curl -X POST https://heathschweitzer.com/api/v1/posts \
  -H "Authorization: Bearer $(grep API_TOKEN .env | cut -d= -f2)" \
  -H "Content-Type: application/json" \
  -d "{
    \"title\": \"Post Title Here\",
    \"content\": $(cat post.md | jq -Rs .),
    \"status\": \"DRAFT\",
    \"tagNames\": [\"tag-one\", \"tag-two\"],
    \"categoryNames\": [\"Technology\"]
  }"

The jq -Rs . idiom is worth knowing — it reads the file as a raw string (-R), slurps it into a single string (-s), and outputs it as a JSON string (-Rs .), handling all escaping automatically. Trying to embed a multi-line markdown file in a JSON payload without this is a reliable way to spend an hour debugging malformed JSON.

Extending This Pattern

The same pattern — Bearer token auth with timingSafeEqual, a clean REST surface, virtual status handling — applies to any content type. Adding endpoints for pages, media metadata, or other content types would follow the same structure.

For agents and automation workflows, the combination of a simple token-gated API and a Markdown-based content model is genuinely powerful. The barrier between "content exists somewhere" and "content is published on the site" is a single curl command.

Tagged

next.jsdeveloperscmsapi

If this post was useful, consider buying me a coffee ☕ with ₿itcoin — no account needed, any amount welcome.

Bitcoin tip QR code
⚡ Open in Wallet