Roll Your Own API Keys

Published: Apr 7, 2025

Last updated: Apr 7, 2025

API keys at their most basic level offer authentication solutions for both your public-facing and internal-facing APIs. At a deeper level, your API keys can open up strategies such as authorization, rate-limiting and tamper-proofing of payloads.

This post will cover some of the fundamentals of rolling your own API keys in TypeScript and Node.js 22, but the knowledge is language-agnostic (and runtime agnostic for that matter). I will also close with some remarks on why TypeScript might not be the language of choice for your API keys if you expect any sort of scale.

Two strategies around API Keys

This post will focus on two strategies around rolling your own API keys:

  1. Static API keys
  2. HMAC-signed API keys

Both strategies have their trade-offs, but if you are time-poor, then I would focus on the keys with request signatures.

We will work through some simple implementations for both that focuses on authentication and, in the case of signed keys, tamper-proofing.

Added functionality like authorization won't be the focus. The tl;dr is that you can use a token identifier to associate things like permissions, rate-limiting etc.

Static keys

Static API keys are simply tokens that a client can include with each request.

Server-side, we verify and ensure the token is valid in order to authenticate the request against our token data store.

You can consider this analogous in ways to providing a password with a request. The key must be sent with every request, kept secret and only transmitted over TLS to prevent eavesdropping.

An example implementation of static keys with a Hono router:

import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; import * as crypto from "node:crypto"; import Keyv from "keyv"; // Headers const apiKeyHeader = z.object({ Authorization: z.string().openapi({ example: "Bearer pk_live_abc123" }), }); // In-memory key store const keyStore = new Keyv({ namespace: "api-key-store" }); function generateApiKey(): string { return `pk_live_${crypto.randomBytes(16).toString("hex")}`; } function hashApiKey(key: string): string { return crypto.createHash("sha256").update(key).digest("hex"); } export class ApiKeysController extends OpenAPIHono { constructor() { super(); this.create(); this.verify(); } public create() { return this.openapi( createRoute({ method: "post", path: "/keys/create", description: "Create an API key and store its SHA-256 hash", responses: { 200: { description: "API key created", content: { "application/json": { schema: z.object({ apiKey: z.string(), }), }, }, }, }, }), async (c) => { const plainKey = generateApiKey(); const hashedKey = hashApiKey(plainKey); await keyStore.set(`hash:${plainKey}`, hashedKey); return c.json({ apiKey: plainKey }); } ); } public verify() { return this.openapi( createRoute({ method: "post", path: "/keys/verify", description: "Verify an API key using SHA-256 lookup", request: { headers: apiKeyHeader, body: { content: { "application/json": { schema: z.object({}), }, }, }, }, responses: { 200: { description: "Verification result", content: { "application/json": { schema: z.object({ valid: z.boolean(), }), }, }, }, }, }), async (c) => { const authHeader = c.req.header("Authorization") ?? ""; const token = authHeader.replace("Bearer ", "").trim(); const expectedHash = await keyStore.get(`hash:${token}`); if (!expectedHash) return c.json({ valid: false }); const isValid = hashApiKey(token) === expectedHash; return c.json({ valid: isValid }); } ); } }

In a real-world application, you should not store a plain text key as part of the database key. In this proof of concept, it's more for listing out the keys to see the data if you wish to do so.

For the sake of understanding our example, I am using the in-memory store from keyv as a stand-in for our data store, @hono/zod-openapi as our augmented Hono server and some helpers from the Node.js crypto module for generating random bytes for our API key and hashing that key to be stored in the database.

Our server has two endpoints:

  1. One for creating API keys at /keys/create.
  2. A second for verifying those API keys at /keys/verify.

In our /keys/create endpoint, we generate an API key, hash it and then store the hash as a key-value in the database under hash:${plainkey}.

We can call our endpoint like so:

curl http://localhost:3000/v1/api-keys/keys/create \ --request POST

If we call our endpoint, we end up with a result like the following:

{ "apiKey": "pk_live_6f6bd5c1b85c5040f4d269ae8c84ce3d" }

If we then provide that API key under the Authorization header at /keys/verify such as the following:

curl http://localhost:3000/v1/api-keys/keys/verify \ --request POST \ --header 'Authorization: Bearer pk_live_6f6bd5c1b85c5040f4d269ae8c84ce3d' \ --header 'Content-Type: application/json' \ --data '{}'

...then we can see that our API key is valid.

{ "valid": true }

For the sake of completion, if you try with an invalid API key then you will get back `valid` as `false`.

Although static keys can be adequate for many cases, they fall down when extra security is needed. Although that can be rolled or revoked easily, simple API keys cannot verify the identity of the caller or prevent tampering of request parameters in transit, so there are weaker integrity guarantees.

We can work around these problems by introducing request signatures.

Keys with request signatures

Request signatures introduce two values to be shared with the client at the time of API key generation:

  1. The access key ID.
  2. The secret access key.

Typically, the secret access key is only shared once for the user, so if they lose the secret access key then they will need to re-generate keys.

The client never shares the secret key back to the server. Instead, the access key ID is sent alongside a signature of the request data which is signed using the secret key.

Once the data is sent, the server looks up the token secret and recomputes the signature. If they match, the request is authenticated and proven integral (which is spiel for not being tampered with).

Let's take a look at how an implementation of this works:

import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; import * as crypto from "node:crypto"; import Keyv from "keyv"; // Headers const apiKeyHeader = z.object({ Authorization: z.string().openapi({ example: "Bearer pk_live_abc123" }), }); const signedHeaders = apiKeyHeader.extend({ "X-Signature": z.string().openapi({ example: "a3d4c9..." }), "X-Timestamp": z.string().openapi({ example: "1712333445" }), }); // In-memory key store const keyStore = new Keyv({ namespace: "api-key-store" }); function generateApiKey(): string { return `pk_live_${crypto.randomBytes(16).toString("hex")}`; } function hashApiKey(key: string): string { return crypto.createHash("sha256").update(key).digest("hex"); } function generateHmac(key: string, secret: string): string { return crypto.createHmac("sha256", secret).update(key).digest("hex"); } export class ApiKeysController extends OpenAPIHono { constructor() { super(); this.verify(); this.secureVerify(); public secureCreate() { return this.openapi( createRoute({ method: "post", path: "/keys/secure-create", description: "Create an HMAC-style API key", responses: { 200: { description: "Key pair created", content: { "application/json": { schema: z.object({ accessKeyId: z.string(), secretAccessKey: z.string(), }), }, }, }, }, }), async (c) => { const accessKeyId = generateApiKey(); const secretAccessKey = crypto.randomBytes(32).toString("hex"); // WARNING: In real-world applications, encrypt this value. await keyStore.set(`hmac:${accessKeyId}`, { secret: secretAccessKey }); return c.json({ accessKeyId, secretAccessKey, // return only once! }); }, ); } public secureVerify() { return this.openapi( createRoute({ method: "post", path: "/keys/secure-verify", description: "Verify HMAC-signed API request using access/secret key", request: { headers: signedHeaders, body: { content: { "application/json": { schema: z.object({ message: z.string(), }), }, }, }, }, responses: { 200: { description: "Verification result", content: { "application/json": { schema: z.object({ valid: z.boolean(), }), }, }, }, }, }), async (c) => { const authHeader = c.req.header("Authorization") ?? ""; const signature = c.req.header("X-Signature") ?? ""; const timestamp = c.req.header("X-Timestamp") ?? ""; const token = authHeader.replace("Bearer ", "").trim(); const stored = await keyStore.get(`hmac:${token}`); if (!stored?.secret) return c.json({ valid: false }); const body = await c.req.json(); const digest = generateHmac( `${timestamp}:${JSON.stringify(body)}`, // WARNING: In real-world applications, this would be an encrypted value // that we should decrypt first. stored.secret, ); const valid = crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(digest), ); return c.json({ valid }); }, ); } }

In real-world applications, if your database is compromised as our current implementation stands, then the attacker will have access to both the public access key and secret access key. You should take steps to mitigate this by storing the encrypted secret, however that sits outside the scope of the current blog post.

In our modified code, we again have two endpoints:

  1. /keys/secure-create
  2. /keys/secure-verify

In addition to this, our /keys/secure-verify endpoint now requires three headers:

  1. Authorization: Our same authorization header.
  2. X-Timestamp: A timestamp to denote when the signing occurred.
  3. X-Signature: Our signed payload.

We will see those headers in action in a moment, but let's park that there for now.

In our /keys/secure-create endpoint, we now return two key pieces of information. Let's see this in action.

curl http://localhost:3000/v1/api-keys/keys/secure-create \ --request POST

This returns the following JSON:

{ "accessKeyId": "pk_live_f5c4672457c78c8e019df66eeabbe33e", "secretAccessKey": "2a39d2316ef07ebb7c763f3eb06b0ef3aafbd7f0b3d0f2b3bd5acfa6e0b57fd5" }

On the server, we store the secret for us to fetch later (please see the callout below on this) in order to be used for validating the signature. We then return both values once. Warn the user to securely store the secret, as we won't be returning that value again.

In order to see how signing works, here is a helper script that I used to test against our verification endpoint /keys/secure-verify:

import crypto from "node:crypto"; import axios from "axios"; const { accessKeyId, secretAccessKey } = { accessKeyId: "pk_live_f5c4672457c78c8e019df66eeabbe33e", secretAccessKey: "2a39d2316ef07ebb7c763f3eb06b0ef3aafbd7f0b3d0f2b3bd5acfa6e0b57fd5", }; const timestamp = Math.floor(Date.now() / 1000).toString(); const body = { message: "Hello, HMAC-secured world!", }; // Compute HMAC using timestamp and JSON body const digest = crypto .createHmac("sha256", secretAccessKey) .update(`${timestamp}:${JSON.stringify(body, null, 0)}`) .digest("hex"); const start = performance.now(); const res = await axios.post( "http://localhost:3000/v1/api-keys/keys/secure-verify", body, { headers: { Authorization: `Bearer ${accessKeyId}`, "X-Timestamp": timestamp, "X-Signature": digest, "Content-Type": "application/json", }, } ); const end = performance.now(); console.log("Time:", end - start); console.log("Response:", res.data);

In the above code we use the crypto.createHmac function in order to sign the request, and then we pass it along as our X-Signature header. The header itself uses update to incorporate our concatenated timestamp and body payload string into the signature.

The data used to verify the signature can include whatever we want, so if there are any extra headers or pieces of data that are important to validating integrity, then be sure to reflect that in the signature.

Once signed, we send a request that includes the required headers. Values such a X-Timestamp are required in order to verify the signature server-side and can also be used to reject signatures older than a certain time period.

In order to compare the signatures on the server-side implementation, we have the following:

const valid = crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(digest) );

crypto.timingSafeEqual is a function that compares the underlying bytes using a constant-time algorithm. It does not leak timing information which is an attack surface for malicious actors and is suitable for comparing HMAC digests.

If you are interested in reading up more about authentication practices like timing attacks, then see the OWASP authentication cheat sheet.

If we run the Node.js script, we will get back the expect Response: { valid: true } payload.

Play around and attempt to tamper some of the values and see what happens. Unsurprisingly, valid is false.

Case study: AWS APIs

Amazon Web Services APIs use a two-part credential: an Access Key ID (public identifier) and a Secret Access Key. Every API request must be signed using a process called Signature Version 4 (SigV4), which is an HMAC-SHA256 scheme​.

The client includes the Access Key ID and the computed signature in an Authorization header. AWS uses the Access Key ID to look up the secret key and then recomputes the signature from the request data on their end​. If it matches, the request is authenticated and processed. If not, it's rejected​.

In a similar setup to our implementation, AWS requires a timestamp (X-Amz-Date header or an HTTP Date header) on each request. This is used to reject requests that are considered stale (5 minutes by default) to protect against replay attacks.

If you are familiar with the AWS APIs, then this process of signing the request is abstracted away from you, but there is documentation that shows you how create a signed AWS API request that is an important reference if you want to cross-reference your implementations against theirs.

An example of a SigV4 Authorization header:

Authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20220830/us-east-1/ec2/aws4_request, SignedHeaders=host;x-amz-date, Signature=calculated-signature

An example of authentication parameters in the query string:

https://ec2.amazonaws.com/? Action=DescribeInstances& Version=2016-11-15& X-Amz-Algorithm=AWS4-HMAC-SHA256& X-Amz-Credential=AKIAIOSFODNN7EXAMPLE/20220830/us-east-1/ec2/aws4_request& X-Amz-Date=20220830T123600Z& X-Amz-SignedHeaders=host;x-amz-date& X-Amz-Signature=calculated-signature

Why slow hashing isn't required

Unlike user passwords, which are usually hashed with slow algorithms like bcrypt, API secrets are often hashed with a standard cryptographic hash (e.g. SHA-256) or stored encrypted.

The reason comes down to entropy and usage patterns. API keys are high-entropy, machine-generated secrets (often 128 bits or more of randomness), whereas user passwords are typically low-entropy (words or common patterns)​.

Because API keys are essentially unguessable by brute force (I wrote this pre-quantum), it's acceptable to use a fast hash for storage.

By contrast, passwords require slow hashing (bcrypt, Argon2, PBKDF2) are used specifically to thwart offline guessing if hashes are stolen​.

Cryptography at scale

Finally, I wanted to touch on your choice for tooling. Node.js can be incredible for I/O bound tasks like HTTP requests, but some of the trade-offs with Node.js is that compute bound tasks such a cryptography operations can be significantly slower for computation time when compared to other runtimes and languages.

If you are building a real-world application, consider taking this into account as it can have real implications on compute costs and auto-scaling nodes. The same concepts apply agnostically across languages, so find that happy point between your estimated scale and what works best for your developers and budgets.

Conclusion

Whether you're shipping a side project or laying the groundwork for production-scale auth, rolling your own API keys is a great exercise in balancing simplicity, security, and developer control.

Start small, sign what matters, and revisit your setup as your needs evolve. Be sure to cross-reference this post with other public APIs when designing your own implementations and ensure that your storage of the API keys is secure and resistant to issues around stolen database data.

Photo credit: mickdepaola

Personal image

Dennis O'Keeffe

Byron Bay, Australia

Dennis O'Keeffe

2020-present Dennis O'Keeffe.

All Rights Reserved.