S
Security

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 toUseThe database holdsResponses show
Keep a field out of some routeshiddenFields in the config of a routeThe value as sentNothing on that route
Keep a field out of every routeselect: false in the schemaThe value as sentNothing on any route
Read a value back later and protect it in the databaseencryptedFields and decryptedFieldsA sealed valueThe plain text on routes that list the field in decryptedFields
Check a value later and never read ithashedFieldsAn Argon2id hashNothing 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:

TypeScript
// 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:

Text
GET /api/v1/products
Text
HTTP/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:

Text
GET /api/v1/products/internal
Text
HTTP/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 __v is 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: false stay 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. 1
    1. Set the masterKey option to 32 random bytes as base64 text. The Environment Variables page shows how to generate one.
    2. Declare the field in the schema with fields.encryptedString.
    3. List the field in encryptedFields on the routes that write it, and in decryptedFields on the routes that may return the plain text.

This collection stores a secret for each note. Only the route that reads one note decrypts it:

TypeScript
// 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:

Text
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:

Text
GET /api/v1/notes/665f1c2e8b3a4d0012a10004
Text
HTTP/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.

Decrypting Is Not Access

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:

PartWhat it is
strThe value, sealed with a public key
publicKeyA public key made for this one value
securedPrivateKeyThe matching private key, encrypted with the master key
nonceThe 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:

TypeScript
// 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:

Text
POST /api/v1/notes
Content-Type: application/json

{
  "title": "Shopping list",
  "secret": ""
}
Text
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:

Text
GET /api/v1/notes/665f1c2e8b3a4d0012a10005
Text
HTTP/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.

Back Up the Key

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:

TypeScript
// 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:

TypeScript
// 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:

Text
DELETE /api/v1/devices/delete/665f1c2e8b3a4d0012a10007
x-device-pin: 0000
Text
HTTP/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"
}
Text
DELETE /api/v1/devices/delete/665f1c2e8b3a4d0012a10007
x-device-pin: 4821
Text
HTTP/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.

Short Values Are Guessable

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

StatuscodeWhen it happensWhat to change
422VALIDATION_FAILEDA 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
422FIELD_MISSINGA 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
500DECRYPTION_FAILEDA 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.

:::