Build a Self-Destructing Secrets API
Build a REST API where secrets self-destruct on expiry, access limits, or burn-after-read.
Introduction
30 Second Summary
Every day, developers share passwords, API keys, and sensitive credentials through chat messages that stick around forever. What if you could send a secret that destroyed itself after being read?
In this project, you will build DocuVault, a REST API that stores temporary secrets and self-destructs them when their time-to-live expires, their access limit is reached, or they are burned after a single read.
What You'll Build
When you're done, you'll fire a cURL command from your terminal to store a secret, grab it back with a GET request that shows exactly how many reads remain in a custom header, and watch it vanish with a 410 Gone status code the moment its countdown hits zero.
By the end of this project, you'll have:
- Create secrets from your terminal and see custom response headers (X-Vault-Secret-Id, X-Expires-At) confirm each one is stored with an expiry countdown.
- Retrieve secrets by ID with flexible output. A single query parameter switches between JSON and raw text.
- Watch secrets self-destruct when time runs out (410 Gone), a wrong passcode is provided (403 Forbidden), or invalid data is submitted (422 Unprocessable Entity).
- Secret Mission: serve secrets as downloadable .txt files with proper Content-Disposition headers.
Are there any prerequisites?
You should be comfortable with JavaScript fundamentals and have a basic understanding of HTTP requests. TypeScript experience is expected for this project.
Before We Start
Before any code hits the screen, let's lock in what you're about to build and why it matters for your backend skills.
Scaffold the Project and Boot the Server
Before you can build any API endpoints, you need a project that Node.js can actually execute. Node.js v24 can run TypeScript files directly by stripping the types at runtime.
This means zero build step and zero extra tooling. In this step, you will create a project with Express, Zod, and TypeScript, then boot a server and prove it responds.
In this step, get ready to:
- Initialize a Node.js project and install Express, Zod, and TypeScript.
- Configure TypeScript for native Node.js execution with no build step.
- Boot a minimal Express server and verify it responds to HTTP requests.
Initialize the project and install dependencies
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir docuvault-api
cd docuvault-api
- Initialize a new Node.js project by running:
npm init -y
You should see a summary of your new package.json printed in the terminal.
- Install the two runtime dependencies by running:
npm install express@5.2.1 zod@4.4.3
- Install the development dependencies by running:
npm install -D typescript@7.0.2 @types/express@5.0.6 @types/node
What are these packages?
- express is the web framework that handles HTTP requests and routing.
- zod validates incoming data at runtime and gives you the TypeScript type for free.
- typescript, @types/express, and @types/node are dev-only. They provide type-checking when you run npx tsc but are never needed at runtime.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the docuvault-api folder.
- Click Open.
The npm init -y command generated a default package.json, but it needs a few edits to work as an ES module project with custom scripts.
- Open package.json in the VS Code Explorer sidebar and replace its entire contents with:
{
"name": "docuvault-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "node --watch src/index.ts",
"typecheck": "npx tsc"
},
"dependencies": {
"express": "^5.2.1",
"zod": "^4.4.3"
},
"devDependencies": {
"typescript": "^7.0.2",
"@types/express": "^5.0.6",
"@types/node": "^24.0.0"
}
}
What did you change?
- "type": "module" tells Node.js to treat .js and .ts files as ES modules (enabling import/export syntax).
- The dev script uses node --watch to auto-restart the server when you save changes.
- The typecheck script runs npx tsc to check your types without emitting any JavaScript.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Seeing red squiggly lines in package.json?
Make sure every string is wrapped in double quotes (including keys). JSON does not allow trailing commas after the last item in an object or array.
Help me fix my package.json formatting
Create the directory structure and tsconfig.json
Your API code will be organized by responsibility into separate folders. Each folder handles one concern (types, schemas, middleware, services, controllers, routes).
- Open VS Code's integrated terminal by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Create the full directory tree by running:
mkdir -p src/types src/schemas src/middleware src/services src/controllers src/routes
- Confirm the directories exist by running:
ls src/
You should see controllers, middleware, routes, schemas, services, and types listed in the output.
Directories not showing up?
Make sure your terminal is in the docuvault-api folder. Run pwd to check. If you see a different path, run cd ~/Desktop/docuvault-api to get back.
Help me figure out why my directories weren't created
TypeScript needs a configuration file to know how strict to be and which module system to target. The Express docs recommend a specific set of options for Node.js native TypeScript execution.
- Create a new file called tsconfig.json in the root of your docuvault-api folder. In VS Code, click the New File icon at the top of the Explorer sidebar and name it tsconfig.json.
- Paste the following content:
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true
}
}
What do these options mean?
- erasableSyntaxOnly ensures you only use TypeScript features that Node.js can strip away (no enum or namespace).
- verbatimModuleSyntax requires explicit import type for type-only imports so Node.js never tries to load a type at runtime.
- noEmit means tsc only checks types. Node.js runs the .ts files directly.
- rewriteRelativeImportExtensions lets you write .ts extensions in import paths, which Node.js resolves natively.
- Save tsconfig.json.
Write a minimal Express server in src/index.ts
With the project scaffolded, you can now write the smallest possible server to prove everything is wired correctly.
- In the VS Code Explorer sidebar, right-click the src folder and select New File.
- Name it index.ts.
- Paste the following code:
import express from "express";
const app = express();
const PORT = 3000;
app.use(express.json());
app.get("/", (req, res) => {
res.json({ message: "DocuVault API is running" });
});
app.listen(PORT, () => {
console.log(`DocuVault API running at http://localhost:${PORT}`);
});
What does this code do?
- express() creates your application instance.
- express.json() is middleware that parses incoming JSON request bodies so you can read req.body later.
- The GET / route is a health-check. It confirms the server is alive and responding.
- app.listen starts the server on port 3000 and logs the URL once it is ready.
- Save src/index.ts.
Before you run this, what do you think you will see in the terminal? A crash, or a success message?
- Start the server by running this in VS Code's integrated terminal:
node src/index.ts
You should see this line printed in your terminal:
DocuVault API running at http://localhost:3000
The server is now running and blocking that terminal. You need a second terminal to send it a request.
- Open a new terminal tab by pressing Ctrl+Shift+` in VS Code (same on macOS and Windows).
- Send a test request by running:
curl http://localhost:3000
You should see this JSON response:
{"message":"DocuVault API is running"}
Seeing 'Cannot find module' or 'ERR_MODULE_NOT_FOUND'?
- Make sure "type": "module" is in your package.json. Without it, Node.js treats the file as CommonJS and the import statement fails.
- Confirm you are running the command from the docuvault-api folder. Run pwd to check.
- Verify node_modules exists. If not, run npm install again.
Help me debug why my Express server won't start
✔️ Awesome, I've got everything!
Great. Make sure you have saved all three files (package.json, tsconfig.json, src/index.ts) and that your server is responding to curl.
ⓧ I'd like to double check the full code
Here is each file in full. Compare yours against these.
package.json:
{
"name": "docuvault-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "node --watch src/index.ts",
"typecheck": "npx tsc"
},
"dependencies": {
"express": "^5.2.1",
"zod": "^4.4.3"
},
"devDependencies": {
"typescript": "^7.0.2",
"@types/express": "^5.0.6",
"@types/node": "^24.0.0"
}
}
tsconfig.json:
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true
}
}
src/index.ts:
import express from "express";
const app = express();
const PORT = 3000;
app.use(express.json());
app.get("/", (req, res) => {
res.json({ message: "DocuVault API is running" });
});
app.listen(PORT, () => {
console.log(`DocuVault API running at http://localhost:${PORT}`);
});
Your development environment is ready and your server is alive. Next up, you will build the endpoints that create and retrieve secrets.
Create and Retrieve Secrets
Your server boots and responds to a health check, but it does not store or return any data yet.
The core of any API is creating and reading resources. In this step, you will build the full vertical slice: types, an in-memory store, a controller, and routes. By the end you will POST a secret and GET it back via cURL.
In this step, get ready to:
- Define a Secret type and build an in-memory storage service.
- Write controller functions that handle POST and GET requests with custom headers.
- Wire routes and verify a full create-and-retrieve round-trip with cURL.
Define types and build the in-memory service
Before any controller or route can work, you need a shape for the data and a place to store it. You will define the Secret interface and a simple in-memory Map to hold secrets.
- Create src/types/secret.types.ts with the following code:
export type ReadMode = "json" | "raw";
export interface Secret {
id: string;
title: string;
content: string;
passcode?: string;
ttlSeconds: number;
maxAccessCount: number;
burnAfterRead: boolean;
accessCount: number;
createdAt: number;
expiresAt: number;
}
What does this code do?
ReadMode is a union type that restricts the response format to either "json" or "raw" plain text.
The Secret interface describes every field a stored secret carries: its ID, content, time-to-live, access limits, and timestamps. TypeScript enforces this shape everywhere you create or read a secret.
- Save src/types/secret.types.ts.
- Create the service file. Paste the following into src/services/secret.service.ts:
import crypto from "node:crypto";
import type { Secret } from "../types/secret.types.ts";
// In-memory store keyed by secret ID
const store = new Map<string, Secret>();
export function createSecret(input: {
title: string;
content: string;
passcode?: string;
ttlSeconds: number;
maxAccessCount: number;
burnAfterRead: boolean;
}): Secret {
const id = crypto.randomUUID();
const now = Date.now();
const secret: Secret = {
id,
title: input.title,
content: input.content,
passcode: input.passcode,
ttlSeconds: input.ttlSeconds,
maxAccessCount: input.maxAccessCount,
burnAfterRead: input.burnAfterRead,
accessCount: 0,
createdAt: now,
expiresAt: now + input.ttlSeconds * 1000,
};
store.set(id, secret);
return secret;
}
What does this code do?
The store is a Map that holds secrets keyed by their unique ID. It lives in memory, so everything resets when the server restarts.
createSecret generates a UUID, calculates the expiry timestamp from the TTL, builds the full Secret object, stores it in the Map, and returns it to the caller.
- Save src/services/secret.service.ts.
- Confirm both files compile by running this in a new terminal:
npx tsc
No output means no errors. The types and service compile cleanly together.
Seeing a type error?
- Confirm the import path in the service uses "../types/secret.types.ts" with the .ts extension. Node.js native TypeScript requires the full extension.
- Check that secret.types.ts uses export interface Secret (not just interface Secret without the export).
- help me fix a TypeScript error in my service file
- Add the helper functions below createSecret in src/services/secret.service.ts:
export function getSecret(id: string): Secret | undefined {
return store.get(id);
}
export function deleteSecret(id: string): boolean {
return store.delete(id);
}
export function incrementAccess(secret: Secret): void {
secret.accessCount++;
}
What do these helpers do?
getSecret looks up a secret by ID and returns undefined if it does not exist. deleteSecret removes it from the Map. incrementAccess bumps the read counter so you can enforce access limits later.
- Save src/services/secret.service.ts.
- Confirm the helpers compile by running:
npx tsc
No output again. Your entire data layer is ready.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/types/secret.types.ts:
export type ReadMode = "json" | "raw";
export interface Secret {
id: string;
title: string;
content: string;
passcode?: string;
ttlSeconds: number;
maxAccessCount: number;
burnAfterRead: boolean;
accessCount: number;
createdAt: number;
expiresAt: number;
}
For src/services/secret.service.ts, confirm you have both chunks in order: the createSecret function (with imports and store above it) followed by getSecret, deleteSecret, and incrementAccess. Scroll up and verify each is present.
Build the controller and router
The controller is where HTTP meets your application logic. Each handler reads from the request, calls the service, and shapes the response with the correct status code and headers.
- Create src/controllers/secret.controller.ts and paste the imports along with the handleCreateSecret function:
import type { Request, Response, NextFunction } from "express";
import {
createSecret,
getSecret,
deleteSecret,
incrementAccess,
} from "../services/secret.service.ts";
import type { ReadMode } from "../types/secret.types.ts";
export function handleCreateSecret(req: Request, res: Response, next: NextFunction): void {
// Destructure fields from request body
const { title, content, passcode, ttlSeconds, maxAccessCount, burnAfterRead } = req.body;
const secret = createSecret({ title, content, passcode, ttlSeconds, maxAccessCount: maxAccessCount ?? 10, burnAfterRead: burnAfterRead ?? false });
res
.status(201)
.set("X-Vault-Secret-Id", secret.id)
.set("X-Expires-At", new Date(secret.expiresAt).toISOString())
.json({
message: "Secret created",
id: secret.id,
expiresAt: new Date(secret.expiresAt).toISOString(),
});
}
What does this code do?
handleCreateSecret destructures the request body, passes it to the service, and returns a 201 Created response.
It sets two custom response headers: X-Vault-Secret-Id (the new secret's ID) and X-Expires-At (when it will expire). The ?? operator provides defaults: 10 max reads and burn-after-read off if the caller omits those fields.
- Save the file.
- Add the handleGetSecret function below handleCreateSecret in the same file:
export function handleGetSecret(req: Request, res: Response, next: NextFunction): void {
// Extract path param and query param
const { secretId } = req.params;
const mode = (req.query.mode as ReadMode) || "json";
const secret = getSecret(secretId);
if (!secret) {
res.status(404).json({ error: "Secret not found" });
return;
}
res.set("X-Access-Remaining", String(secret.maxAccessCount - secret.accessCount));
if (mode === "raw") {
res.type("text/plain").send(secret.content);
} else {
res.json({
id: secret.id,
title: secret.title,
content: secret.content,
accessCount: secret.accessCount,
maxAccessCount: secret.maxAccessCount,
expiresAt: new Date(secret.expiresAt).toISOString(),
});
}
}
What does this code do?
handleGetSecret reads the secretId path parameter and the optional mode query parameter from the request.
If the secret is not found, it returns 404 Not Found. Otherwise it sets an X-Access-Remaining header and returns the content as JSON or plain text depending on the mode value.
- Save src/controllers/secret.controller.ts.
- Confirm the controller compiles by running:
npx tsc
No output means your controller types are correct.
Getting import errors?
- Make sure all import paths end with .ts. Node.js native TypeScript requires the full extension.
- Type-only imports must use import type because verbatimModuleSyntax is enabled in your tsconfig.
- help me fix import errors in my controller file
- Create src/routes/secret.routes.ts to define the API routes:
import { Router } from "express";
import {
handleCreateSecret,
handleGetSecret,
} from "../controllers/secret.controller.ts";
export const secretRouter = Router();
// POST / creates a new secret
secretRouter.post("/", handleCreateSecret);
// GET /:secretId retrieves a secret by its ID
secretRouter.get("/:secretId", handleGetSecret);
What does this code do?
The Router groups related endpoints together. POST / maps to secret creation. GET /:secretId uses a path parameter (the colon prefix) so Express extracts the ID from the URL and puts it in req.params.secretId.
- Save src/routes/secret.routes.ts.
- Now wire the router into your server. In src/index.ts, find this line at the top:
import express from "express";
- Add the router import directly below it so the top of the file looks like this:
import express from "express";
import { secretRouter } from "./routes/secret.routes.ts";
- Find the app.listen call and add the router mount directly above it:
app.use("/api/v1/secrets", secretRouter);
- Save src/index.ts.
Your terminal running node --watch auto-reloads the server. You should see the startup message print again.
Why mount at /api/v1/secrets?
The /api/v1 prefix versions your API so you can ship breaking changes under /api/v2 later without breaking existing clients. The router's internal paths (/ and /:secretId) are appended to this prefix.
✔️ Awesome, I've got everything!
Great. Make sure all files are saved before testing.
ⓧ I'd like to double check the full code
For src/controllers/secret.controller.ts, confirm you have both chunks in order: the imports and handleCreateSecret followed by handleGetSecret. Scroll up and verify each is present.
Here is the complete src/routes/secret.routes.ts:
import { Router } from "express";
import {
handleCreateSecret,
handleGetSecret,
} from "../controllers/secret.controller.ts";
export const secretRouter = Router();
secretRouter.post("/", handleCreateSecret);
secretRouter.get("/:secretId", handleGetSecret);
Here is the complete src/index.ts:
import express from "express";
import { secretRouter } from "./routes/secret.routes.ts";
const app = express();
const PORT = 3000;
app.use(express.json());
app.get("/", (req, res) => {
res.json({ message: "DocuVault API is running" });
});
app.use("/api/v1/secrets", secretRouter);
app.listen(PORT, () => {
console.log(`DocuVault API running at http://localhost:${PORT}`);
});
Test the round-trip with cURL
The full vertical slice is in place. Time to prove it works by sending real HTTP requests.
- Before you run the next command, predict: what HTTP status code should a successful creation return?
- POST a new secret by running this cURL command in a separate terminal:
curl -s -D - -X POST http://localhost:3000/api/v1/secrets \
-H "Content-Type: application/json" \
-d '{"title":"Launch Codes","content":"The answer is 42","ttlSeconds":300,"maxAccessCount":5,"burnAfterRead":false}'
What does this command do?
- -s silences the progress bar.
- -D - prints response headers to stdout so you can see your custom headers.
- -X POST sets the HTTP method.
- -H adds the Content-Type request header telling the server the body is JSON.
- -d sends the JSON body with the secret's title, content, TTL, access limit, and burn flag.
You should see a 201 Created status line, your custom X-Vault-Secret-Id and X-Expires-At headers, and a JSON body containing the secret's ID and expiry time.
- Copy the id value from the JSON response. You will use it in the next command.
- GET the secret by running this command, replacing the ID placeholder with your actual value:
curl -s http://localhost:3000/api/v1/secrets/[[SECRET_ID="your-secret-id-from-the-POST-response"]]
You should see a JSON response containing the secret's title, content, access count, max access count, and expiry time.
- Try the raw text mode by adding the mode query parameter:
curl -s http://localhost:3000/api/v1/secrets/[[SECRET_ID="your-secret-id-from-the-POST-response"]]?mode=raw
This time the response is just the plain text content: The answer is 42. No JSON wrapping, no metadata.
Getting 404 or connection refused?
- Check that your server is still running. Look at the terminal where you started node --watch src/index.ts for any crash messages.
- Make sure the URL path is /api/v1/secrets (not /secrets).
- Confirm app.use("/api/v1/secrets", secretRouter) appears before app.listen in src/index.ts.
- help me debug why my cURL request is failing
Your API now creates secrets and retrieves them with custom headers, path parameters, and query parameters. Next up, you will add self-destruct mechanics that make secrets expire, burn after reading, and enforce access limits.
Add Self-Destruct Mechanics
Your API can create and retrieve secrets, but right now every secret lives forever. A secret-sharing API without expiry is just a database with extra steps.
In this step, you will add the logic that makes DocuVault interesting. Secrets will self-destruct when their time-to-live expires, when their access limit is reached, or when burn-after-read fires. Each scenario returns a different HTTP status code, teaching you how to communicate precise outcomes to API consumers.
In this step, get ready to:
- Add TTL expiry and access-count limits that return 410 Gone.
- Add passcode protection (403 Forbidden), burn-after-read, and a DELETE endpoint (204 No Content).
- Discover a validation gap where garbage input causes silent failures.
Add TTL and access-count logic to the GET handler
When a consumer GETs a secret, the API needs to answer three questions before serving content: has the TTL expired, has the max access count been reached, and is the secret still accessible?
- In src/controllers/secret.controller.ts, find the handleGetSecret function.
- Replace the entire function body (everything between the opening { and closing }) with the following code:
export function handleGetSecret(req: Request, res: Response, next: NextFunction): void {
const { secretId } = req.params;
const mode = (req.query.mode as ReadMode) || "json";
const secret = getSecret(secretId);
if (!secret) {
res.status(404).json({ error: "Secret not found" });
return;
}
// Check if the secret's time-to-live has expired
if (Date.now() > secret.expiresAt) {
deleteSecret(secretId);
res.status(410).json({ error: "Secret has expired" });
return;
}
// Check if the access limit has been reached
if (secret.accessCount >= secret.maxAccessCount) {
deleteSecret(secretId);
res.status(410).json({ error: "Max access count reached" });
return;
}
What does this code do?
Two new guard clauses run before any content is served:
- Date.now() > secret.expiresAt compares the current timestamp to the secret's expiry. If the TTL has passed, the secret is deleted from the store and the API returns 410 Gone.
- secret.accessCount >= secret.maxAccessCount checks whether the secret has been read too many times. If so, it is also deleted and returns 410 Gone.
410 Gone is the correct status code here because the resource existed previously but has been intentionally and permanently removed. This is different from 404 Not Found, which is ambiguous about whether the resource ever existed.
- Immediately below the access-count check, add the response logic:
incrementAccess(secret);
const remaining = secret.maxAccessCount - secret.accessCount;
res.set("X-Access-Remaining", String(remaining));
if (mode === "raw") {
res.type("text/plain").send(secret.content);
} else {
res.json({
id: secret.id,
title: secret.title,
content: secret.content,
accessCount: secret.accessCount,
maxAccessCount: secret.maxAccessCount,
expiresAt: new Date(secret.expiresAt).toISOString(),
});
}
}
What does the response section do?
- incrementAccess(secret) bumps the read counter before serving content.
- X-Access-Remaining is a custom response header telling the consumer how many reads are left.
- The mode query parameter lets the consumer choose between JSON and raw text output.
- Save src/controllers/secret.controller.ts.
- Make sure your server is running with npm run dev. Test TTL expiry by creating a secret with a 5-second TTL:
curl -s -X POST http://localhost:3000/api/v1/secrets \
-H "Content-Type: application/json" \
-d '{"title":"TTL test","content":"expires soon","ttlSeconds":5,"maxAccessCount":10}'
Before you run the next command, what do you think will happen if you wait 6 seconds and then try to GET the secret?
- Wait 6 seconds, then GET the secret using the ID from the response:
curl -s http://localhost:3000/api/v1/secrets/<secret-id-from-above>
You should see {"error":"Secret has expired"} with a 410 status. The TTL did its job.
Still getting the secret content back?
- Make sure you waited at least 6 seconds. The TTL is 5 seconds, so requesting at exactly 5 seconds might still succeed.
- Confirm you saved the file. The --watch flag auto-restarts the server on save, but unsaved changes are not picked up.
- Check that the if (Date.now() > secret.expiresAt) block appears immediately after the 404 check.
Help me debug why my TTL expiry check is not returning 410 Gone.
Add passcode protection and burn-after-read
Two more self-destruct mechanics remain. Passcode protection gates access behind a custom request header. Burn-after-read destroys the secret the instant it is served, guaranteeing a single read.
- In src/controllers/secret.controller.ts, find the access-count check inside handleGetSecret:
if (secret.accessCount >= secret.maxAccessCount) {
deleteSecret(secretId);
res.status(410).json({ error: "Max access count reached" });
return;
}
- Add the passcode check between the TTL check and the access-count check. Place this code directly above the if (secret.accessCount >= ...) block:
// Check if the request has the correct passcode
if (secret.passcode) {
const provided = req.headers["x-vault-passcode"] as string | undefined;
if (provided !== secret.passcode) {
res.status(403).json({ error: "Invalid passcode" });
return;
}
}
What does the passcode check do?
The X-Vault-Passcode header is a custom request header. The API reads it from req.headers["x-vault-passcode"] and compares it to the stored passcode. If it does not match (or is missing), the API returns 403 Forbidden.
The check only runs if the secret actually has a passcode set. Secrets created without a passcode skip this block entirely.
- Now replace the response section (from incrementAccess(secret) through the closing } of the function) with this updated version that adds burn-after-read:
incrementAccess(secret);
const burned = secret.burnAfterRead;
const remaining = secret.maxAccessCount - secret.accessCount;
// If burn-after-read is enabled, delete immediately
if (burned) {
deleteSecret(secretId);
}
res.set("X-Access-Remaining", String(remaining));
res.set("X-Secret-Burned", String(burned));
if (mode === "raw") {
res.type("text/plain").send(secret.content);
} else {
res.json({
id: secret.id,
title: secret.title,
content: secret.content,
accessCount: secret.accessCount,
maxAccessCount: secret.maxAccessCount,
expiresAt: new Date(secret.expiresAt).toISOString(),
burned,
});
}
}
What does burn-after-read do?
- burned captures whether this secret should self-destruct after being read once.
- If burned is true, deleteSecret(secretId) removes it from the store immediately after serving the content.
- X-Secret-Burned is a custom response header that tells the consumer whether the secret was destroyed on this read.
- Below handleGetSecret, add the handleDeleteSecret function for explicit deletion:
export function handleDeleteSecret(req: Request, res: Response, next: NextFunction): void {
const { secretId } = req.params;
const secret = getSecret(secretId);
if (!secret) {
res.status(404).json({ error: "Secret not found" });
return;
}
deleteSecret(secretId);
res.status(204).send();
}
Why 204 No Content?
204 No Content means the server successfully processed the request but has no response body to return. This is the standard status code for successful DELETE operations in REST APIs.
- Save src/controllers/secret.controller.ts.
- Now wire the DELETE route. In src/routes/secret.routes.ts, replace the entire file contents with:
import { Router } from "express";
import {
handleCreateSecret,
handleGetSecret,
handleDeleteSecret,
} from "../controllers/secret.controller.ts";
export const secretRouter = Router();
secretRouter.post("/", handleCreateSecret);
secretRouter.get("/:secretId", handleGetSecret);
secretRouter.delete("/:secretId", handleDeleteSecret);
What changed in the routes file?
- handleDeleteSecret is added to the import list.
- secretRouter.delete("/:secretId", handleDeleteSecret) maps HTTP DELETE requests to the handler.
- Save src/routes/secret.routes.ts.
- Test passcode protection by creating a passcode-locked secret:
curl -s -X POST http://localhost:3000/api/v1/secrets \
-H "Content-Type: application/json" \
-d '{"title":"Locked secret","content":"top secret data","ttlSeconds":60,"maxAccessCount":10,"passcode":"abc123"}'
- What do you think will happen if you try to GET this secret without providing the passcode?
- Try it without the X-Vault-Passcode header:
curl -s http://localhost:3000/api/v1/secrets/<secret-id-from-above>
You should see {"error":"Invalid passcode"} with a 403 status. The passcode guard is working.
- Now provide the correct passcode via the custom header:
curl -s http://localhost:3000/api/v1/secrets/<secret-id-from-above> \
-H "X-Vault-Passcode: abc123"
This time you should see the full secret JSON with X-Access-Remaining and X-Secret-Burned headers in the response.
Getting 403 even with the correct passcode?
- Header names are case-insensitive in HTTP, but the code reads req.headers["x-vault-passcode"] (all lowercase). Make sure your cURL header uses X-Vault-Passcode (cURL lowercases it automatically).
- Confirm the passcode value matches exactly. Check for trailing spaces or quotes in your cURL command.
Help me debug why my X-Vault-Passcode header is not being accepted by the API.
Discover the validation gap
Your self-destruct mechanics rely on numeric comparisons. The TTL check compares Date.now() to secret.expiresAt. But what happens when the input data is not actually a number?
- POST a secret with intentionally wrong types to see what happens:
curl -s -X POST http://localhost:3000/api/v1/secrets \
-H "Content-Type: application/json" \
-d '{"title":"Bad data","content":123,"ttlSeconds":"banana","maxAccessCount":10}'
Before you look at the response, what do you expect? Should the API reject this request or accept it?
The API responds with 201 Created. It accepted the garbage input without complaint.
- Now GET that secret using the ID from the response:
curl -s http://localhost:3000/api/v1/secrets/<secret-id-from-above>
The secret is returned successfully. It never expires because "banana" * 1000 produces NaN, and Date.now() > NaN is always false. Your carefully built TTL logic silently does nothing.
Why is this a problem?
Without input validation, any garbage data that arrives in req.body flows straight into the service layer. Numeric fields receive strings, boolean fields receive numbers, and required fields are missing entirely.
The result is silent corruption. The API happily stores nonsense and then behaves unpredictably. In the next step, you will add runtime validation with Zod to reject bad input before it reaches the service.
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/controllers/secret.controller.ts and src/routes/secret.routes.ts.
ⓧ I'd like to double check the full code
Here is the full controller file. Scroll through and confirm each section matches yours:
import type { Request, Response, NextFunction } from "express";
import {
createSecret,
getSecret,
deleteSecret,
incrementAccess,
} from "../services/secret.service.ts";
import type { ReadMode } from "../types/secret.types.ts";
export function handleCreateSecret(req: Request, res: Response, next: NextFunction): void {
const { title, content, passcode, ttlSeconds, maxAccessCount, burnAfterRead } = req.body;
const secret = createSecret({ title, content, passcode, ttlSeconds, maxAccessCount: maxAccessCount ?? 10, burnAfterRead: burnAfterRead ?? false });
res
.status(201)
.set("X-Vault-Secret-Id", secret.id)
.set("X-Expires-At", new Date(secret.expiresAt).toISOString())
.json({
message: "Secret created",
id: secret.id,
expiresAt: new Date(secret.expiresAt).toISOString(),
});
}
export function handleGetSecret(req: Request, res: Response, next: NextFunction): void {
const { secretId } = req.params;
const mode = (req.query.mode as ReadMode) || "json";
const secret = getSecret(secretId);
if (!secret) {
res.status(404).json({ error: "Secret not found" });
return;
}
if (Date.now() > secret.expiresAt) {
deleteSecret(secretId);
res.status(410).json({ error: "Secret has expired" });
return;
}
if (secret.passcode) {
const provided = req.headers["x-vault-passcode"] as string | undefined;
if (provided !== secret.passcode) {
res.status(403).json({ error: "Invalid passcode" });
return;
}
}
if (secret.accessCount >= secret.maxAccessCount) {
deleteSecret(secretId);
res.status(410).json({ error: "Max access count reached" });
return;
}
incrementAccess(secret);
const burned = secret.burnAfterRead;
const remaining = secret.maxAccessCount - secret.accessCount;
if (burned) {
deleteSecret(secretId);
}
res.set("X-Access-Remaining", String(remaining));
res.set("X-Secret-Burned", String(burned));
if (mode === "raw") {
res.type("text/plain").send(secret.content);
} else {
res.json({
id: secret.id,
title: secret.title,
content: secret.content,
accessCount: secret.accessCount,
maxAccessCount: secret.maxAccessCount,
expiresAt: new Date(secret.expiresAt).toISOString(),
burned,
});
}
}
export function handleDeleteSecret(req: Request, res: Response, next: NextFunction): void {
const { secretId } = req.params;
const secret = getSecret(secretId);
if (!secret) {
res.status(404).json({ error: "Secret not found" });
return;
}
deleteSecret(secretId);
res.status(204).send();
}
And here is the full routes file:
import { Router } from "express";
import {
handleCreateSecret,
handleGetSecret,
handleDeleteSecret,
} from "../controllers/secret.controller.ts";
export const secretRouter = Router();
secretRouter.post("/", handleCreateSecret);
secretRouter.get("/:secretId", handleGetSecret);
secretRouter.delete("/:secretId", handleDeleteSecret);
Your secrets now self-destruct on schedule, burn on read, and lock behind passcodes. But as you discovered, none of that matters if the API accepts garbage in the first place. In the next step, you will add Zod validation to reject bad input at the door.
Add Runtime Validation with Zod
In the last step, you discovered that POST requests with garbage data like "banana" for ttlSeconds are silently accepted. The TTL logic then fails silently because comparing NaN always returns false.
The fix is runtime validation. Zod lets you define a schema that rejects bad input before it ever reaches the service layer. Even better, Zod gives you the TypeScript type for free via z.infer. One schema, zero duplicate type definitions.
In this step, get ready to:
- Define a Zod schema that enforces correct types on every incoming secret.
- Build a centralized error middleware that returns structured 422 responses.
- Verify that invalid requests are now rejected with detailed error messages.
Define the Zod schema and infer the type
A Zod schema is a single source of truth. It describes the shape of valid input and exports a TypeScript type that stays perfectly in sync with it.
- Create a new file src/schemas/secret.schema.ts and add the following schema:
import * as z from "zod";
// Define the shape of a valid secret creation request
export const createSecretSchema = z.object({
title: z.string().min(1),
content: z.string().min(1),
ttlSeconds: z.number().int().positive(),
maxAccessCount: z.number().int().positive().default(10),
burnAfterRead: z.boolean().default(false),
passcode: z.string().min(1).optional(),
});
// Infer the TypeScript type directly from the schema
export type CreateSecretInput = z.infer<typeof createSecretSchema>;
What does this code do?
Each field in z.object() declares what type it must be and any constraints (minimum length, positive integer). If input doesn't match, Zod throws an error with structured details about every failing field.
The .default(10) and .optional() calls mean those fields don't need to appear in the request. The z.infer<typeof createSecretSchema> line extracts a TypeScript type that exactly mirrors the schema. No manual type definition needed.
Now update the service to use this inferred type instead of the manual inline type annotation.
- In src/services/secret.service.ts, find the imports at the top of the file:
import crypto from "node:crypto";
import type { Secret } from "../types/secret.types.ts";
- Add the new import for CreateSecretInput so the imports look like this:
import crypto from "node:crypto";
import type { Secret } from "../types/secret.types.ts";
import type { CreateSecretInput } from "../schemas/secret.schema.ts";
- Next, find the createSecret function signature:
export function createSecret(input: {
title: string;
content: string;
passcode?: string;
ttlSeconds: number;
maxAccessCount: number;
burnAfterRead: boolean;
}): Secret {
- Replace the entire inline type with CreateSecretInput so the function signature looks like this:
export function createSecret(input: CreateSecretInput): Secret {
- Save src/schemas/secret.schema.ts and src/services/secret.service.ts.
- Restart your server and POST a valid secret to confirm nothing broke:
curl -s -X POST http://localhost:3000/api/v1/secrets \
-H "Content-Type: application/json" \
-d '{"title": "Test", "content": "Hello", "ttlSeconds": 60}'
You should still see a 201 response with the secret ID. The schema and type are wired, but validation isn't enforced yet.
Seeing a type error or import failure?
- Make sure the import path in secret.service.ts ends with .ts. Node.js native TypeScript execution requires file extensions in imports.
- Verify the import uses import type (not import). The verbatimModuleSyntax setting in your tsconfig requires explicit type keyword for type-only imports.
- help me debug a type error in my service file
Validate in the controller and build the error middleware
The schema exists, but the controller isn't using it yet. You need to call createSecretSchema.parse() on the request body. If input is invalid, Zod throws an error that Express 5 automatically forwards to error middleware.
- In src/controllers/secret.controller.ts, find the imports at the top:
import type { Request, Response, NextFunction } from "express";
import {
createSecret,
getSecret,
deleteSecret,
incrementAccess,
} from "../services/secret.service.ts";
import type { ReadMode } from "../types/secret.types.ts";
- Add the schema import on line 2, so the full import block looks like this:
import type { Request, Response, NextFunction } from "express";
import { createSecretSchema } from "../schemas/secret.schema.ts";
import {
createSecret,
getSecret,
deleteSecret,
incrementAccess,
} from "../services/secret.service.ts";
import type { ReadMode } from "../types/secret.types.ts";
- Now find the first two lines inside handleCreateSecret:
const { title, content, passcode, ttlSeconds, maxAccessCount, burnAfterRead } = req.body;
const secret = createSecret({ title, content, passcode, ttlSeconds, maxAccessCount: maxAccessCount ?? 10, burnAfterRead: burnAfterRead ?? false });
- Replace those two lines with this:
const parsed = createSecretSchema.parse(req.body);
const secret = createSecret(parsed);
What does .parse() do?
Calling .parse(req.body) validates the request body against every rule in your schema. If validation passes, it returns the cleaned data (with defaults applied). If it fails, it throws a ZodError.
You don't need a try-catch here. Express 5 automatically catches both synchronous throws and async rejections, then forwards them to error-handling middleware.
- Save src/controllers/secret.controller.ts and restart your server.
- POST invalid data to see what happens without a custom error handler:
curl -s -X POST http://localhost:3000/api/v1/secrets \
-H "Content-Type: application/json" \
-d '{"ttlSeconds": "banana", "content": 123}'
You should see an HTML error page or a generic 500 response. Express caught the thrown ZodError, but without a custom error handler it doesn't know how to format it. That's what you'll fix next.
- Create src/middleware/error.middleware.ts with the following error handler:
import type { Request, Response, NextFunction } from "express";
// Express recognizes this as error middleware because it has 4 parameters
export function errorMiddleware(
err: any,
req: Request,
res: Response,
next: NextFunction
): void {
// Zod errors have an issues array
if (err.issues && Array.isArray(err.issues)) {
res.status(422).json({
error: "Validation failed",
issues: err.issues,
});
return;
}
// Catch-all for unexpected errors
console.error("Unhandled error:", err);
res.status(500).json({ error: "Internal server error" });
}
What does this code do?
Express identifies error-handling middleware by its 4-parameter signature (err, req, res, next). Any error thrown in a route handler or middleware is forwarded here automatically.
The duck-type check err.issues && Array.isArray(err.issues) identifies Zod validation errors. When matched, it returns a 422 Unprocessable Entity with the structured issues array. Everything else becomes a 500.
- In src/index.ts, find the imports at the top:
import express from "express";
import { secretRouter } from "./routes/secret.routes.ts";
- Add the error middleware import so the imports look like this:
import express from "express";
import { secretRouter } from "./routes/secret.routes.ts";
import { errorMiddleware } from "./middleware/error.middleware.ts";
- Find the line app.use("/api/v1/secrets", secretRouter); and add the error middleware directly below it:
app.use("/api/v1/secrets", secretRouter);
app.use(errorMiddleware);
Why must the error middleware be last?
Express executes middleware in the order you register it. Error middleware must come after all routes so it can catch errors thrown anywhere upstream. If you mount it before your routes, thrown errors have nowhere to go.
- Save src/middleware/error.middleware.ts and src/index.ts.
- Restart your server.
Server won't start?
- Check that the import path to error.middleware.ts starts with ./middleware/ (relative to index.ts).
- Make sure you have exactly 4 parameters in the function signature. Express only recognizes it as error middleware when all four are declared.
- help me fix my error middleware setup
Verify validation works end-to-end
Before you run these tests, predict: what do you think the API will return when you send "banana" as the TTL value?
- POST a request with invalid types to confirm the 422 response:
curl -s -X POST http://localhost:3000/api/v1/secrets \
-H "Content-Type: application/json" \
-d '{"ttlSeconds": "banana", "content": 123}'
You should see a 422 Unprocessable Entity response with a structured issues array showing exactly which fields failed and why. The "banana" that used to slip through is now blocked at the door.
- Now confirm that valid requests still work by running:
curl -s -X POST http://localhost:3000/api/v1/secrets \
-H "Content-Type: application/json" \
-d '{"title": "Launch codes", "content": "abc-123", "ttlSeconds": 300}'
You should see a 201 response with the X-Vault-Secret-Id and X-Expires-At headers. Zod applied the defaults for maxAccessCount (10) and burnAfterRead (false) automatically.
Why no try-catch in the controller?
Express 5 catches both synchronous throws and async Promise rejections in route handlers. When .parse() throws, Express automatically forwards the error to the next error-handling middleware. You get clean controller code with zero boilerplate.
Still seeing a 500 instead of 422?
- Confirm app.use(errorMiddleware) appears AFTER app.use("/api/v1/secrets", secretRouter) in your src/index.ts. Order matters.
- Check that errorMiddleware has exactly 4 parameters in its signature. Express silently ignores 3-parameter functions as error handlers.
- help me debug my 422 validation response
✔️ Awesome, I've got everything!
Great. Double check you've saved all files before moving on.
ⓧ I'd like to double check the full code
Here are the files you created or modified in this step.
import * as z from "zod";
export const createSecretSchema = z.object({
title: z.string().min(1),
content: z.string().min(1),
ttlSeconds: z.number().int().positive(),
maxAccessCount: z.number().int().positive().default(10),
burnAfterRead: z.boolean().default(false),
passcode: z.string().min(1).optional(),
});
export type CreateSecretInput = z.infer<typeof createSecretSchema>;
import type { Request, Response, NextFunction } from "express";
export function errorMiddleware(
err: any,
req: Request,
res: Response,
next: NextFunction
): void {
if (err.issues && Array.isArray(err.issues)) {
res.status(422).json({
error: "Validation failed",
issues: err.issues,
});
return;
}
console.error("Unhandled error:", err);
res.status(500).json({ error: "Internal server error" });
}
import express from "express";
import { secretRouter } from "./routes/secret.routes.ts";
import { errorMiddleware } from "./middleware/error.middleware.ts";
const app = express();
const PORT = 3000;
app.use(express.json());
app.get("/", (req, res) => {
res.json({ message: "DocuVault API is running" });
});
app.use("/api/v1/secrets", secretRouter);
app.use(errorMiddleware);
app.listen(PORT, () => {
console.log(`DocuVault API running at http://localhost:${PORT}`);
});
For src/services/secret.service.ts and src/controllers/secret.controller.ts, scroll up and confirm: the service imports CreateSecretInput and uses it as the function parameter type, and the controller imports createSecretSchema and calls .parse(req.body) instead of destructuring.
Your API now rejects garbage input before it ever touches the service layer. Next up, you'll add a middleware pipeline that protects every endpoint with authentication and logs every request with timing data.
Build the Middleware Pipeline
Your API validates input and returns proper status codes, but anyone who knows the server URL can create and read secrets freely. That is a problem for a secrets API.
Middleware solves cross-cutting concerns like logging and authentication without polluting your controllers. In this step, you will build a logger that times every request and an auth guard that blocks unauthenticated callers.
In this step, get ready to:
- Build a logger middleware that sets an X-Response-Time header on every response.
- Build an auth guard middleware that requires a Bearer token on protected routes.
- Wire the full middleware pipeline in the correct sequential order.
Build the logger middleware
Every production API needs observability. A logger middleware intercepts every request, measures how long it takes, and writes a structured log line to the console.
- Create a new file at src/middleware/logger.middleware.ts and add the following code:
import type { Request, Response, NextFunction } from "express";
export function loggerMiddleware(req: Request, res: Response, next: NextFunction): void {
const start = performance.now();
const originalSend = res.send;
res.send = function (this: Response, body?: any): Response {
const duration = (performance.now() - start).toFixed(2);
this.set("X-Response-Time", `${duration}ms`);
console.log(
`[${new Date().toISOString()}] ${req.method} ${req.originalUrl} ${this.statusCode} ${duration}ms`
);
res.send = originalSend;
return originalSend.call(this, body);
} as typeof res.send;
next();
}
What does this code do?
- performance.now() records the exact moment the request arrives.
- The function overrides res.send with a wrapper that calculates the total duration just before the response leaves the server.
- The wrapper sets X-Response-Time as a response header so clients can see latency, then logs a structured line to the console.
- After logging, the wrapper restores the original res.send and calls it so the response ships normally.
- Save src/middleware/logger.middleware.ts.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
import type { Request, Response, NextFunction } from "express";
export function loggerMiddleware(req: Request, res: Response, next: NextFunction): void {
const start = performance.now();
const originalSend = res.send;
res.send = function (this: Response, body?: any): Response {
const duration = (performance.now() - start).toFixed(2);
this.set("X-Response-Time", `${duration}ms`);
console.log(
`[${new Date().toISOString()}] ${req.method} ${req.originalUrl} ${this.statusCode} ${duration}ms`
);
res.send = originalSend;
return originalSend.call(this, body);
} as typeof res.send;
next();
}
Now wire the logger into your Express app so it runs on every incoming request.
- In src/index.ts, find the import section at the top. Add this import after the secretRouter import:
import { loggerMiddleware } from "./middleware/logger.middleware.ts";
- Find the line app.use(express.json()); and add this line directly below it:
app.use(loggerMiddleware);
- Save src/index.ts. The --watch flag restarts the server automatically.
- Verify the logger is working by running this in a second terminal:
curl -i http://localhost:3000
You should see an X-Response-Time header in the response (something like X-Response-Time: 0.42ms). In your server terminal, a log line confirms the logger is working.
Don't see X-Response-Time in the response?
- Make sure app.use(loggerMiddleware) appears after app.use(express.json()) but before any route definitions. Order matters in Express.
- Confirm the import path matches the filename exactly: ./middleware/logger.middleware.ts.
- Check that you used curl -i (lowercase i). This flag tells curl to show response headers.
Help me debug my logger middleware
Build the auth guard middleware
The logger proves requests are flowing, but nothing stops an unauthenticated caller from accessing your secrets endpoints. An auth guard reads the Authorization header and blocks requests that lack a valid Bearer token.
- Create a new file at src/middleware/auth.middleware.ts and add the following code:
import type { Request, Response, NextFunction } from "express";
const VALID_TOKEN = process.env.VAULT_TOKEN || "docuvault-secret-token-2024";
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
res.status(401).json({ error: "Missing or invalid Authorization header" });
return;
}
const token = authHeader.slice(7);
if (token !== VALID_TOKEN) {
res.status(401).json({ error: "Invalid token" });
return;
}
next();
}
What does this code do?
- VALID_TOKEN reads from the VAULT_TOKEN environment variable. If unset, it falls back to a hardcoded development token.
- The guard checks that the Authorization header exists and starts with Bearer . If not, it returns 401 Unauthorized immediately.
- authHeader.slice(7) extracts the token portion after the 7-character prefix. If it does not match the valid token, the request is rejected.
- Only when both checks pass does the middleware call next(), handing control to the next handler in the pipeline.
- Save src/middleware/auth.middleware.ts.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
import type { Request, Response, NextFunction } from "express";
const VALID_TOKEN = process.env.VAULT_TOKEN || "docuvault-secret-token-2024";
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
res.status(401).json({ error: "Missing or invalid Authorization header" });
return;
}
const token = authHeader.slice(7);
if (token !== VALID_TOKEN) {
res.status(401).json({ error: "Invalid token" });
return;
}
next();
}
Now wire the auth guard into src/index.ts so it protects all /api/v1 routes while leaving the health-check endpoint public.
- In src/index.ts, add this import after the loggerMiddleware import you just added:
import { authMiddleware } from "./middleware/auth.middleware.ts";
- Find the line app.use("/api/v1/secrets", secretRouter); and add this line directly above it:
app.use("/api/v1", authMiddleware);
Why mount auth on /api/v1 instead of globally?
Mounting on /api/v1 means only routes under that prefix require a token. The GET / health-check stays public so monitoring tools and load balancers can reach it without credentials.
- Save src/index.ts. The server restarts automatically.
- Verify the auth guard blocks unauthenticated requests by running:
curl -i http://localhost:3000/api/v1/secrets
You should see 401 with the JSON body {"error":"Missing or invalid Authorization header"}.
Still getting 200 or seeing your secrets?
- Make sure app.use("/api/v1", authMiddleware) appears before app.use("/api/v1/secrets", secretRouter) in src/index.ts. Express runs middleware in registration order.
- Confirm the path string is exactly /api/v1 (no trailing slash).
Help me debug my auth middleware
Test the full middleware pipeline
Both middleware functions are wired. Time to prove the full pipeline works end-to-end: logger first, auth second, routes third, error handler last.
- First, confirm the health-check endpoint is still public by running:
curl -i http://localhost:3000
You should see 200 with X-Response-Time in the headers and {"message":"DocuVault API is running"} in the body. No auth required.
Before you run the next command, predict: what do you expect to see when you POST a valid secret with the correct Bearer token?
- Create a secret using the full auth header by running:
curl -i -X POST http://localhost:3000/api/v1/secrets \
-H "Content-Type: application/json" \
-H "Authorization: Bearer docuvault-secret-token-2024" \
-d '{"title":"Pipeline Test","content":"Auth works!","ttlSeconds":300}'
You should see 201 Created with X-Vault-Secret-Id, X-Expires-At, and X-Response-Time all present in the response headers.
- Check your server terminal. You should see a log line like:
[2026-08-11T10:30:00.000Z] POST /api/v1/secrets 201 1.23ms
What is the middleware execution order?
Express runs middleware in the order you register it with app.use(). Your pipeline flows like this:
- express.json() parses the request body.
- loggerMiddleware starts the timer and intercepts the response.
- authMiddleware checks the Bearer token (only on /api/v1 routes).
- Route handlers run your controller logic.
- errorMiddleware catches any thrown errors and sends structured responses.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/index.ts with all three middleware imports and registrations.
ⓧ I'd like to double check the full code
import express from "express";
import { secretRouter } from "./routes/secret.routes.ts";
import { loggerMiddleware } from "./middleware/logger.middleware.ts";
import { authMiddleware } from "./middleware/auth.middleware.ts";
import { errorMiddleware } from "./middleware/error.middleware.ts";
const app = express();
const PORT = 3000;
app.use(express.json());
app.use(loggerMiddleware);
app.get("/", (req, res) => {
res.json({ message: "DocuVault API is running" });
});
app.use("/api/v1", authMiddleware);
app.use("/api/v1/secrets", secretRouter);
app.use(errorMiddleware);
app.listen(PORT, () => {
console.log(`DocuVault API running at http://localhost:${PORT}`);
});
Your API is now fully protected and observable. Every request gets timed, every protected route demands a token, and every error gets caught. The middleware pipeline is complete.
Secret mission
Add a File Download Endpoint
Your API returns secrets as JSON or plain text. But what if a user wants to save a secret as an actual file on their machine? Build a download endpoint that serves secrets as .txt file attachments with proper Content-Disposition headers.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with an in-memory store, so there are no ongoing costs.
Resources you used:
- The docuvault-api project directory on your Desktop (contains all source code and node_modules).
- The running Node.js development server on port 3000.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with new endpoints.
- Your server continues running at http://localhost:3000. Send more cURL requests to test edge cases.
- If you closed the terminal, restart the server by navigating to the docuvault-api folder and running npm run dev.
Pause - I'll come back to this later
Shut down the running server to free up port 3000 and system memory. Your files stay on disk.
- Switch to the terminal where the server is running.
- Press Ctrl+C to stop the process.
- When you're ready to resume, navigate to docuvault-api and run npm run dev to start the server again.
Delete - I don't want to use this again
Remove all project files and start fresh. This deletes everything you built in this project.
- Stop the server first by pressing Ctrl+C in the terminal where it is running.
- Delete the project directory by running this command from your Desktop:
macOS
cd ~/Desktop
rm -rf docuvault-api
Windows
cd ~\Desktop
Remove-Item -Recurse -Force docuvault-api
- Confirm the folder is gone by running ls (macOS) or dir (Windows). You should no longer see docuvault-api in the listing.
Nice Work!
Nice Work!
You just built a production-patterned REST API from scratch. DocuVault creates, retrieves, and self-destructs secrets with proper HTTP semantics, strict validation, and a layered middleware pipeline.
You've learned how to:
- Build a full REST API with Express 5 and TypeScript 7 that runs natively on Node.js without a build step, using custom headers, path parameters, and query parameters for flexible resource access.
- Validate incoming request bodies at runtime with Zod schemas and infer TypeScript types directly from them using z.infer, returning structured 422 responses when validation fails.
- Wire a sequential middleware pipeline (logger, auth guard, error handler) that returns the correct HTTP status code for every scenario (201, 204, 401, 403, 404, 410, 422, 500).
- Secret Mission: Served secret content as downloadable .txt files with Content-Disposition: attachment headers.
Ready to quiz yourself?