Hidden and Encrypted Fields
Keep sensitive fields out of responses, encrypt them at rest and hash values before saving
Some fields should not travel the way ordinary fields do. StarkForge has three tools for them, and each answers a different question. hiddenFields keeps a field out of a response. encryptedFields stores a value so that a copy of the database cannot read it, and decryptedFields returns the plain text on the routes that you choose. hashedFields stores a one-way hash that you can check later but never read back. This page shows when to use which, how each one works, and what you see when something goes wrong.
Choose the Right Tool
The tools protect different things. Hiding a field and select: false only filter what a response shows, and the database still holds the value as it was sent. Encryption and hashing change what the database holds.
| You need to | Use | The database holds | Responses show |
|---|---|---|---|
| Keep a field out of some routes | hiddenFields in the config of a route | The value as sent | Nothing on that route |
| Keep a field out of every route | select: false in the schema | The value as sent | Nothing on any route |
| Read a value back later and protect it in the database | encryptedFields and decryptedFields | A sealed value | The plain text on routes that list the field in decryptedFields |
| Check a value later and never read it | hashedFields | An Argon2id hash | Nothing on any route |
Hide a Field on One Route
config.hiddenFields lists fields of the document that a route removes from its response. One collection can hide a field on the public list and show it on an admin route. This collection keeps the internal stock code sku out of the public list:
// src/collections/products.ts
import { collection, defineRoutes, fields } from "@starklabs/forge";
import { z } from "zod";
export const products = collection({
type: "crud",
route: "products",
model: "Product",
schema: {
name: fields.requiredString,
price: fields.requiredNumber,
// An internal stock code.
sku: { mongoose: { type: String, unique: true, sparse: true }, zod: z.string().optional() },
},
validations: {
create: { name: z.string().min(1).max(120), price: z.number().min(0), sku: z.string().optional() },
},
routes: defineRoutes([
{ method: "get", path: "/internal", handler: "readAll", auth: "admin" },
{ method: "get", path: "/", handler: "readAll", auth: "public", config: { hiddenFields: ["sku"] } },
{ method: "post", path: "/", handler: "create", auth: "authenticated", roles: ["editor", "admin"], validation: "create" },
]),
});Anyone can list the products, and the response has no sku:
GET /api/v1/productsHTTP/1.1 200 OK
Content-Type: application/json
{
"success": true,
"message": "Product list fetched successfully.",
"data": [
{
"name": "Monitor arm",
"price": 59,
"_id": "665f1c2e8b3a4d0012a10001",
"createdAt": "2026-01-15T10:30:00.000Z",
"updatedAt": "2026-01-15T10:30:00.000Z"
}
]
}An administrator who calls the other route gets the same product with its code:
GET /api/v1/products/internalHTTP/1.1 200 OK
Content-Type: application/json
{
"success": true,
"message": "Product list fetched successfully.",
"data": [
{
"name": "Monitor arm",
"price": 59,
"sku": "MA-004",
"_id": "665f1c2e8b3a4d0012a10001",
"createdAt": "2026-01-15T10:30:00.000Z",
"updatedAt": "2026-01-15T10:30:00.000Z"
}
]
}Four rules apply to the list:
- It names fields of the document itself. It does not reach into populated documents, which Relations explains.
"_id"can be hidden like any other field.- A name that starts with
!opts back in to a field that is hidden by default. Only__vis hidden by default, so"!__v"returns the Mongoose version key. - Secrets cannot be opted in. Password hashes, token hashes and every field that the schema declares with
select: falsestay hidden, whatever the list says. Responses shows everything that StarkForge always removes.
Hiding a field filters the response and nothing else. The field is still stored, and a client can still write it when the validation group of the route includes it.
Keep a Field Out of Every Query
A schema field declared with select: false is not loaded when StarkForge reads documents, so no route returns it and a client cannot sort a list by it. The built-in password field works this way. A field of your own looks like this: token: { mongoose: { type: String, select: false }, zod: z.string().optional() }.
Your own code can still load such a field. In an action, ctx.repository.findById(id, { withSecrets: ["token"] }) returns the document with token included. The response sanitizer removes the field by name from every response, including populated documents.
Encrypt a Field
Encryption protects a value inside the database: someone who copies the database cannot read it without the master key. Three things make it work:
- 1
- Set the
masterKeyoption to 32 random bytes as base64 text. The Environment Variables page shows how to generate one. - Declare the field in the schema with
fields.encryptedString. - List the field in
encryptedFieldson the routes that write it, and indecryptedFieldson the routes that may return the plain text.
- Set the
This collection stores a secret for each note. Only the route that reads one note decrypts it:
// src/collections/notes.ts
import { collection, defineRoutes, fields } from "@starklabs/forge";
import { z } from "zod";
export const notes = collection({
type: "crud",
route: "notes",
model: "Note",
schema: {
title: fields.requiredString,
secret: fields.encryptedString,
owner: fields.userRef,
},
validations: {
create: {
title: z.string().min(1).max(120),
secret: z.string().optional(),
},
},
routes: defineRoutes([
{
method: "post",
path: "/",
handler: "create",
auth: "authenticated",
validation: "create",
encryptedFields: ["secret?"],
},
{ method: "get", path: "/", handler: "readAll", auth: "admin-or-owner" },
{
method: "get",
path: "/:id",
handler: "read",
auth: "admin-or-owner",
decryptedFields: ["secret?"],
},
]),
});The question mark in "secret?" makes the field optional. Without it, a create request that leaves the field out fails with FIELD_MISSING. The same rule applies to decryptedFields, as the errors table at the end shows.
A route that uses encryptedFields or decryptedFields needs the key when the app starts. Without masterKey, the app refuses to start:
Invalid Forge configuration:
- masterKey is required because a route uses encryptedFields or decryptedFields. Generate one with generateMasterKey().The owner of a note reads it back in plain text. The list of the same notes has no secret, because that route does not list the field in decryptedFields:
GET /api/v1/notes/665f1c2e8b3a4d0012a10004HTTP/1.1 200 OK
Content-Type: application/json
{
"success": true,
"message": "Note fetched successfully.",
"data": {
"title": "Shopping list",
"owner": "665f1c2e8b3a4d0012a10002",
"_id": "665f1c2e8b3a4d0012a10004",
"createdAt": "2026-01-15T10:30:00.000Z",
"updatedAt": "2026-01-15T10:30:00.000Z",
"secret": "my bank PIN is 1234"
}
}A field that a route also lists in hiddenFields is never returned and never decrypted, even when the same route lists it in decryptedFields.
A route that lists a field in decryptedFields returns the plain text to everyone who may call the route. The access level of the route decides who that is, so use admin-or-owner or admin for routes that decrypt.
What the Database Holds
An encrypted value is stored as an object with four text parts:
| Part | What it is |
|---|---|
str | The value, sealed with a public key |
publicKey | A public key made for this one value |
securedPrivateKey | The matching private key, encrypted with the master key |
nonce | The random number used to encrypt the private key |
Every value gets its own key pair, so two notes with the same text look different in the database. StarkForge uses libsodium for this: crypto_box_seal seals the value and crypto_secretbox_easy protects the private key. The test below creates a note and looks at the stored document, then reads the note through the route that decrypts:
// tests/notes.test.ts
import { fields } from "@starklabs/forge";
import { createTestForge, type TestForge } from "@starklabs/forge/testing";
import { afterAll, beforeAll, expect, it } from "vitest";
import { notes } from "../src/collections/notes.js";
let t: TestForge;
beforeAll(async () => {
t = await createTestForge({
builtinConfig: {
auth: {
mode: "builtin",
signupMode: "credentials",
loginMode: "credentials",
mongooseConfig: {
model: "User",
schema: {
name: fields.requiredString,
email: fields.email,
password: fields.password,
role: fields.role,
},
},
},
},
collections: [notes],
});
await t.client.post("/auth/signup", {
name: "Ada Lovelace",
email: "[email protected]",
password: "correct-horse-battery",
});
});
afterAll(async () => {
await t.close();
});
it("stores a sealed value and returns plain text only where the route asks", async () => {
const created = await t.client.post("/notes", {
title: "Shopping list",
secret: "my bank PIN is 1234",
});
const id = created.body.data._id;
const stored = (await t.data.repository("Note").findById(id))!;
expect(Object.keys(stored.secret).sort()).toEqual(["nonce", "publicKey", "securedPrivateKey", "str"]);
expect(JSON.stringify(stored)).not.toContain("1234");
const one = await t.client.get(`/notes/${id}`);
expect(one.body.data.secret).toBe("my bank PIN is 1234");
const list = await t.client.get("/notes");
expect(list.body.data[0]).not.toHaveProperty("secret");
});Limits of Encrypted Fields
Because every value has its own key pair, an encrypted field cannot do what a plain field does:
- You cannot search or filter by it, because two equal values never look equal.
- You cannot sort a list by it. A list route refuses with
INVALID_QUERY. - You cannot put a unique index on it.
- It holds a non-empty string. To encrypt a number or an object, convert it to text first.
A blank value is a client mistake, and StarkForge answers it with 422 instead of failing inside the encryption code:
POST /api/v1/notes
Content-Type: application/json
{
"title": "Shopping list",
"secret": ""
}HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"success": false,
"message": "'secret' must not be empty.",
"code": "VALIDATION_FAILED",
"requestId": "5720e3a2-ab10-40d0-a931-9bc86191290b"
}Give the field a validation rule that fits, such as z.string().min(1), so that the client gets an issues entry with the field name before this check runs.
Lose or Change the Master Key
The master key is the only way back to the plain text. StarkForge has no key rotation. The key also seals the two-factor secrets, so after a change the second step of sign-in fails for everyone who turned two-factor on. A value that was sealed with another key does not open, and a route that decrypts it answers 500:
GET /api/v1/notes/665f1c2e8b3a4d0012a10005HTTP/1.1 500 Internal Server Error
Content-Type: application/json
{
"success": false,
"message": "Decryption failed. The master key may have changed.",
"code": "DECRYPTION_FAILED",
"requestId": "d8a7adaf-7f28-41ad-afc0-33faa7bc37bb"
}Routes that do not decrypt keep working, so the list of the same notes still answers 200. Restore the key that sealed the data and the notes open again.
Losing or replacing MASTER_KEY destroys every encrypted value and every two-factor secret, and nothing in StarkForge can recover them. Keep the key in a secret manager, keep a copy outside the server, and generate it once.
Hash a Field
A hash is one way. hashedFields replaces the value with an Argon2id hash before the document is saved, and nothing can turn the hash back into the value. Use it for values that you only need to check, such as a PIN or an API key. The hash is hidden on every route of the collection, including routes that do not list the field.
The check uses PasswordHasher, which @starklabs/forge exports. Its verify method takes the plain value and the stored hash and answers true or false. This action protects the delete route of a device with its PIN:
// src/actions/require-pin.ts
import { ForbiddenError, PasswordHasher, type ActionContext } from "@starklabs/forge";
const hasher = new PasswordHasher();
export async function requirePin(ctx: ActionContext) {
const device = await ctx.repository?.findById(String(ctx.req.params.id));
const pin = String(ctx.req.headers["x-device-pin"] ?? "");
// One answer for a missing device and a wrong PIN, so nobody can probe which ids exist.
if (!device || !(await hasher.verify(pin, device.pin))) {
throw new ForbiddenError({ message: "The PIN is not correct.", code: "PIN_INCORRECT" });
}
}The collection hashes the PIN when a device is created, and the delete route runs the action first:
// src/collections/devices.ts
import { collection, defineRoutes, fields } from "@starklabs/forge";
import { z } from "zod";
import { requirePin } from "../actions/require-pin.js";
export const devices = collection({
type: "crud",
route: "devices",
model: "Device",
schema: {
name: fields.requiredString,
pin: fields.requiredString,
},
validations: {
create: {
name: z.string().min(1).max(80),
pin: z.string().min(4).max(12),
},
},
routes: defineRoutes([
{ method: "get", path: "/", handler: "readAll", auth: "authenticated" },
{
method: "post",
path: "/",
handler: "create",
auth: "authenticated",
validation: "create",
hashedFields: ["pin"],
},
{
method: "delete",
path: "/delete/:id",
handler: "remove",
auth: "authenticated",
actions: { before: [{ customAction: requirePin }] },
},
]),
});The response to a create request has no pin, and neither has any list. A delete request with the wrong PIN is refused, and one with the right PIN deletes the device:
DELETE /api/v1/devices/delete/665f1c2e8b3a4d0012a10007
x-device-pin: 0000HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"success": false,
"message": "The PIN is not correct.",
"code": "PIN_INCORRECT",
"requestId": "11670446-2158-4458-98a1-147c59a9f5a8"
}DELETE /api/v1/devices/delete/665f1c2e8b3a4d0012a10007
x-device-pin: 4821HTTP/1.1 200 OK
Content-Type: application/json
{
"success": true,
"message": "Device deleted successfully.",
"data": {
"name": "Front door",
"_id": "665f1c2e8b3a4d0012a10007",
"createdAt": "2026-01-15T10:30:00.000Z",
"updatedAt": "2026-01-15T10:30:00.000Z"
}
}The hash comes from libsodium (crypto_pwhash_str with its interactive limits). It is slow on purpose, so a bulk request with a hashed field takes longer the more items it holds. When the validation rule lets a blank value through, hashedFields refuses it with 'pin' must not be empty., the same answer that an encrypted field gives.
Hashing does not make a short value safe. Someone who copies the database can try all 10,000 four-digit PINs offline. A hash protects long random values best, such as an API key made of 32 random bytes.
Errors
| Status | code | When it happens | What to change |
|---|---|---|---|
| 422 | VALIDATION_FAILED | A field of encryptedFields or hashedFields is an empty string or not a string. The message is 'secret' must not be empty. or 'secret' must be a string. | Send a non-empty string, or leave an optional field out |
| 422 | FIELD_MISSING | A field without a question mark is missing from the body ('secret' is required.), or a document has no value for a field of decryptedFields ('secret' has no stored value to decrypt.) | Send the field, or add a question mark |
| 500 | DECRYPTION_FAILED | A stored value does not open with the current master key. The message is Decryption failed. The master key may have changed. | Restore the key that sealed the data |
Where to Go Next
Environment Variables
Generate the master key and keep it out of your code.
Responses
See everything that StarkForge never returns.
:::