返回博客

Building Secure APIs with Token Authentication and OpenAPI

2025-11-2312 min read

Why I needed API tokens

I've been building a web app with a dashboard for managing content. Everything worked fine through the UI, but I started thinking about automation. What if I want to use ChatGPT to manage content? Or automate repetitive tasks with scripts?

I needed an API with proper authentication. Something secure, easy to use, and well-documented.

Here's what I built and what I learned.

The requirements

Before diving into code, I mapped out what I actually needed:

  • Secure token generation: No guessable patterns, cryptographically secure
  • One-time display: Show the token once, never again (like GitHub)
  • Never store plaintext: Hash everything before it hits the database
  • Revocation support: Let users delete tokens they no longer need
  • Usage tracking: Show when and how tokens are being used
  • OpenAPI documentation: Make it easy for AI assistants to understand the API
  • Interactive testing: Swagger UI for developers to try endpoints

Sounds like a lot, right? Let me break down how each piece works.

Part 1: Generating secure tokens

The first challenge was creating tokens that are both secure and user-friendly. I settled on this format:

imk_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6

Let me explain each part:

  • imk - My product prefix (short for "Imerkar")
  • live - Environment (live vs test)
  • a1b2c3... - 32 random characters

This format is inspired by Stripe's API keys, and there's good reason for it:

  1. Prefix scanning - If someone accidentally commits a token to GitHub, tools can detect it by scanning for the imk_ prefix
  2. Environment separation - You can tell at a glance if it's a test or live key
  3. User-friendly identification - Users can identify keys by their prefix even after the full token is hidden

The generation code

I used the Web Crypto API for secure random generation:

async function generateApiKey(): Promise<string> {
// Generate 24 bytes (192 bits) of random data
const randomBytes = new Uint8Array(24);
crypto.getRandomValues(randomBytes);

// Convert to base64url encoding (URL-safe, no padding)
const randomString = btoa(String.fromCharCode(...randomBytes))
.replace(/\+/g, '-')
.replace(/\/g, '_')
.replace(/=/g, '');

return `imk_live_${randomString}`;
}

This generates cryptographically secure random tokens. The crypto.getRandomValues() function uses the operating system's entropy source, making tokens unpredictable. I use base64url encoding to ensure the token is URL-safe and maintains full entropy.

Part 2: Storing tokens securely

Here's the golden rule: never store API tokens in plaintext.

Think about it like passwords. If your database gets compromised, you don't want attackers to have working API keys. So just like passwords, we hash the tokens before storage.

The database schema

CREATE TABLE api_keys (
id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
name TEXT NOT NULL,
key_prefix TEXT NOT NULL, -- Prefix for display (imk_live_xxx)
key_hash TEXT NOT NULL, -- SHA-256 hash
scopes TEXT NOT NULL, -- JSON array of permissions
last_used_at TEXT, -- ISO 8601 timestamp
last_used_ip TEXT,
expires_at TEXT, -- ISO 8601 timestamp
revoked INTEGER DEFAULT 0,
created_at TEXT NOT NULL -- ISO 8601 timestamp
);

-- Index for fast authentication lookups
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash);

-- Index for listing customer's keys
CREATE INDEX idx_api_keys_customer ON api_keys(customer_id);

Notice I store the key_prefix (the readable part like imk_live_abc) separately. This lets me show users something like:

imk_live_a1b2c3d4... (Created Jan 15, 2025)

They can identify which token is which without seeing the full value.

Hashing the tokens

async function hashApiKey(apiKey: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(apiKey);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

When a user creates a token:

  1. Generate the random token
  2. Hash it with SHA-256
  3. Store the hash in the database
  4. Return the full token to the user (only this once!)
  5. Never show the full token again

Part 3: Authentication middleware

Now that we can generate and store tokens, we need to actually authenticate requests. I built middleware that supports multiple authentication methods:

  1. Clerk JWT tokens - For logged-in dashboard users
  2. API tokens - For external integrations

The authentication flow

async function authenticate(c: Context) {
const authHeader = c.req.header('Authorization');

if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Missing authorization' }, 401);
}

const token = authHeader.substring(7);

// Try API key authentication
if (token.startsWith('imk_')) {
const customerId = await verifyApiKey(token);
if (customerId) {
c.set('customerId', customerId);
return;
}
}

// Try JWT authentication
const customerId = await verifyJWT(token);
if (customerId) {
c.set('customerId', customerId);
return;
}

return c.json({ error: 'Invalid token' }, 401);
}

Verifying API tokens

When an API key comes in, here's what happens:

async function verifyApiKey(apiKey: string): Promise<string | null> {
// Hash the provided key
const keyHash = await hashApiKey(apiKey);

// Look up the hash in the database
const key = await db.query(
'SELECT customer_id, revoked, expires_at FROM api_keys WHERE key_hash = ?',
[keyHash]
);

if (!key) return null;
if (key.revoked) return null;
if (key.expires_at && new Date(key.expires_at) < new Date()) return null;

// Update last used timestamp
await db.execute(
'UPDATE api_keys SET last_used_at = ?, last_used_ip = ? WHERE key_hash = ?',
[new Date().toISOString(), c.req.header('cf-connecting-ip'), keyHash]
);

return key.customer_id;
}

This is fast because we index the key_hash column. Database lookup is O(1) thanks to the index.

Part 4: The dashboard UI

Users need a way to create and manage their tokens. I built this into the Settings page of my dashboard.

The user experience

  1. Navigate to Settings → API Keys
  2. Click "Create New API Key"
  3. Enter a name (e.g., "Zapier Integration")
  4. Click Create
  5. See the full token with a big warning:
⚠️ API Key Created

imk_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6

Save this key now. You won't be able to see it again!
  1. Copy to clipboard
  2. After closing, only see the prefix:
Zapier Integration
imk_live_a1b2c3d4...
Created: Jan 15, 2025
Last used: Jan 16, 2025
[Revoke]

The React component

Here's a simplified version of the key creation flow:

function ApiKeysTab() {
const [keys, setKeys] = useState([]);
const [showCreateModal, setShowCreateModal] = useState(false);
const [newKey, setNewKey] = useState(null);

async function createKey(name: string) {
const response = await fetch('/api/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});

const data = await response.json();
setNewKey(data.key); // Full key, shown only once
setShowCreateModal(false);
}

async function revokeKey(keyId: string) {
await fetch(`/api/api-keys/${keyId}`, { method: 'DELETE' });
loadKeys(); // Refresh the list
}

return (
<>
{newKey && (
<Alert>
<AlertTitle>API Key Created</AlertTitle>
<AlertDescription>
<code>{newKey}</code>
<p>Save this now. You won't see it again!</p>
</AlertDescription>
</Alert>
)}

{keys.map(key => (
<KeyRow
key={key.id}
name={key.name}
prefix={key.key_prefix}
lastUsed={key.last_used_at}
onRevoke={() => revokeKey(key.id)}
/>
))}
</>
);
}

Part 5: OpenAPI documentation

Here's where it gets really cool. Once you have an API, you need documentation. But not just any documentation. Machine-readable documentation that AI assistants can understand.

That's where OpenAPI (formerly Swagger) comes in.

What is OpenAPI?

OpenAPI is a standard way to describe REST APIs. It's a JSON/YAML file that tells you:

  • What endpoints exist
  • What parameters they accept
  • What they return
  • How to authenticate
  • What errors might occur

The beauty is that ChatGPT, Claude, Zapier, and hundreds of other tools can read OpenAPI specs and automatically understand your API.

Building the OpenAPI document

I created an OpenAPI 3.0 spec for my API:

const openAPIDocument = {
openapi: '3.0.0',
info: {
title: 'Imerkar API',
version: '1.0.0',
description: 'Build and manage websites programmatically',
},
servers: [
{
url: 'https://app.imerkar.com',
description: 'Production server',
},
],
components: {
securitySchemes: {
BearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT or API Key',
description: 'Use your API key with Bearer authentication',
},
},
},
security: [{ BearerAuth: [] }],
paths: {
'/api/pages': {
get: {
summary: 'List all pages',
tags: ['Pages'],
responses: {
'200': {
description: 'List of pages',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
pages: {
type: 'array',
items: { $ref: '#/components/schemas/Page' }
}
}
}
}
}
}
}
},
post: {
summary: 'Create a new page',
tags: ['Pages'],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['title'],
properties: {
title: { type: 'string' },
description: { type: 'string' },
}
}
}
}
},
responses: {
'201': {
description: 'Page created',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Page' }
}
}
}
}
}
}
}
};

This might look verbose, but it's incredibly powerful. With this one document, any tool can understand your entire API.

Serving the OpenAPI spec

I created two endpoints:

// Serve the JSON spec
app.get('/api/docs/openapi.json', (c) => {
return c.json(openAPIDocument);
});

// Serve the YAML spec (some tools prefer YAML)
app.get('/api/docs/openapi.yaml', (c) => {
const yaml = convertToYAML(openAPIDocument);
return c.text(yaml, 200, {
'Content-Type': 'application/x-yaml'
});
});

Part 6: Swagger UI for interactive testing

OpenAPI specs are great, but developers want to test your API, not just read about it. That's where Swagger UI comes in.

Swagger UI is a web interface that:

  • Reads your OpenAPI spec
  • Generates interactive documentation
  • Lets developers test endpoints directly in the browser
  • Shows request/response examples

Setting up Swagger UI

I created a simple HTML page that loads Swagger UI from a CDN:

app.get('/api/docs', (c) => {
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Imerkar API Documentation</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui.css">
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui-bundle.js"></script>
<script>
window.onload = function() {
SwaggerUIBundle({
url: '/api/docs/openapi.json',
dom_id: '#swagger-ui',
persistAuthorization: true,
tryItOutEnabled: true,
});
};
</script>
</body>
</html>
`;

return c.html(html);
});

Now when developers visit https://app.imerkar.com/api/docs, they see a beautiful, interactive API explorer.

Using Swagger UI

The workflow is simple:

  1. Open the docs at /api/docs
  2. Click "Authorize"
  3. Paste your API key: imk_live_...
  4. Click an endpoint (e.g., GET /api/pages)
  5. Click "Try it out"
  6. Click "Execute"
  7. See the real response from your API

It's like Postman, but built into your documentation.

Part 7: Integrating with ChatGPT

Here's where it all comes together. With an OpenAPI spec, you can create a Custom GPT that understands your API.

Creating a Custom GPT

  1. Go to ChatGPT → Create a GPT
  2. Add an Action
  3. Import from URL: https://app.imerkar.com/api/docs/openapi.json
  4. Set authentication to Bearer Token
  5. Add your API key
  6. Test it:
You: "List all my pages"

ChatGPT: Let me check your pages...
[Makes GET /api/pages request]

ChatGPT: "You have 5 pages:
1. Home
2. About Us
3. Services
4. Contact
5. Blog"

You: "Create a new page called 'Pricing' with a description about our plans"

ChatGPT: [Makes POST /api/pages request]

ChatGPT: "Done! I created a new page called 'Pricing' with your description."

It just works. ChatGPT reads your OpenAPI spec and knows how to use your API.

Part 8: Usage tracking and rate limiting

Once you have API tokens in the wild, you need to track how they're being used and prevent abuse.

Usage logging

I created a simple usage log table:

CREATE TABLE api_key_usage_logs (
id TEXT PRIMARY KEY,
api_key_id TEXT NOT NULL,
endpoint TEXT NOT NULL,
method TEXT NOT NULL,
status_code INTEGER NOT NULL,
ip_address TEXT,
created_at TEXT NOT NULL -- ISO 8601 timestamp
);

-- Index for rate limiting queries
CREATE INDEX idx_usage_logs_key_time ON api_key_usage_logs(api_key_id, created_at);

Every API request gets logged:

async function logApiUsage(keyId: string, req: Request, status: number) {
await db.execute(
`INSERT INTO api_key_usage_logs (id, api_key_id, endpoint, method, status_code, ip_address, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
generateId(),
keyId,
new URL(req.url).pathname,
req.method,
status,
req.headers.get('cf-connecting-ip'),
new Date().toISOString()
]
);
}

Rate limiting

I implemented simple rate limiting:

  • 30 requests per minute per API key
  • 1000 requests per day
async function checkRateLimit(keyId: string): Promise<boolean> {
const oneMinuteAgo = new Date(Date.now() - 60000).toISOString();

const count = await db.query(
'SELECT COUNT(*) as count FROM api_key_usage_logs WHERE api_key_id = ? AND created_at > ?',
[keyId, oneMinuteAgo]
);

return count.count < 30;
}

If the limit is exceeded, return a helpful error:

{
"error": "Rate limit exceeded",
"code": "RATE_LIMIT_EXCEEDED",
"details": {
"limit": 30,
"window": "1 minute",
"retryAfter": 42
}
}

What I learned

Building this feature taught me a lot:

1. Security is not optional

Never store plaintext tokens. Hash everything. Use crypto.subtle for randomness. Think about what happens if your database leaks.

2. UX matters for developer tools

API tokens need good UX too. Show them once with a big warning. Let users name their tokens. Show when they were last used. Make revocation easy.

3. OpenAPI is a superpower

Writing an OpenAPI spec feels like busywork at first, but it's incredibly valuable. It enables ChatGPT integration, Swagger UI, client SDK generation, and testing automation.

4. Start simple, iterate

I didn't build all of this at once. Version 1 was just token generation and authentication. Then I added the dashboard UI. Then OpenAPI docs. Then usage tracking. Each iteration made it better.

5. Database indexes are crucial

Indexing the key_hash column makes authentication fast. Without it, every request would scan the entire table. With it, lookups are instant.

The tech stack

For reference, here's what I used:

  • Backend: Cloudflare Workers + Hono framework
  • Database: Cloudflare D1 (SQLite)
  • Frontend: React + Tailwind CSS
  • Auth (sessions): Clerk
  • OpenAPI: Hand-written spec (could use @hono/zod-openapi for type safety)
  • Language: TypeScript

Next steps

There's still more I want to add:

  • Scope-based permissions - Right now, tokens have full access. I want to add scopes like pages:read, pages:write, media:read, etc.
  • Webhook support - Let users subscribe to events (page.created, page.published, etc.)
  • SDK generation - Use the OpenAPI spec to auto-generate client libraries in Python, JavaScript, PHP, etc.
  • Better analytics - More detailed usage stats with graphs and trends
  • IP whitelisting - Let users restrict tokens to specific IP addresses

Resources

If you're building something similar, these resources helped me:

保持更新

在您的收件箱中获取最新的文章和见解。

Unsubscribe anytime. No spam, ever.