With identity verified (07-01) and the channel encrypted (07-02), an attacker can no longer read Ana's token or pose as Orders. But they can still be a legitimate user sending a malicious request: an id with odd characters, a 50 MB JSON, a MongoDB filter disguised as a string, or simply GET /v1/orders/ord-88214 to see whether it slips through. This lesson is about what each service does with what it receives and with what it stores: the principles that organize everything else, the OWASP API Security Top 10 walked through with TechCorp examples, input validation and sanitization with zod, injections (SQL, NoSQL, commands, logs), headers with helmet, dependencies, secrets in code, protection of personal data (GDPR at a practical level), auditing, security testing and vulnerability response. It ends with the checklist that gets added to the techcorp/node-service-template template. Authentication (07-01), TLS (07-02) and containers/Kubernetes (07-04) are only linked to.

Notice. The measures in this lesson are the usual ones for a development team, not a complete security program. The actual application of the GDPR, of PCI DSS and of any retention or audit policy must be reviewed with a security professional and with the organization's compliance/data protection officer.

Contents

  1. Principles that organize the rest
  2. OWASP API Security Top 10 (2023) with TechCorp examples
  3. Input validation and sanitization with zod
  4. Injection: SQL, NoSQL, commands and logs
  5. Security headers with helmet
  6. Dependency management
  7. Secrets in code: never, and how to make sure
  8. Protecting personal data: GDPR in practice
  9. Auditing sensitive actions
  10. Security testing: SAST, DAST, review and pentest
  11. Vulnerability response
  12. Per-service security checklist

  1. Principles that organize the rest

Six principles that recur in every decision of this module:

Principle Meaning Already applied at TechCorp
Least privilege Each component can only do what it needs svc_* users per schema (02-04), RabbitMQ permissions per queue (07-02), scopes per client (07-01)
Defense in depth Several independent layers; none is the only one JWT verified at the gateway and in the service; TLS and HMAC signature; validation and parameterized queries
Fail secure When in doubt, deny; on error, do not reveal AuthorizationPolicy with explicit ALLOW; 500 INTERNAL_ERROR without a stack trace (04-02); invalid config → it does not start (04-03)
Minimal surface Fewer doors, less risk Inventory/Payments/Notifications not exposed (03-04); x-powered-by disabled; image without development npm (05-01)
Security by design Decided at design time, not patched at the end Resource owner in the use case; events without unnecessary data (02-05)
Shift left Check as early as possible in the cycle Lint, tests, npm audit, Trivy and gitleaks in CI (05-03) before deploying

  1. OWASP API Security Top 10 (2023) with TechCorp examples

The OWASP API Security Top 10 is the reference list of API risks. Walking through it with your own cases is the best way to internalize it:

# Risk What it would look like at TechCorp Remedy and where
API1 BOLA (Broken Object Level Authorization) Ana requests GET /v1/orders/ord-88214 (another customer's) and gets it Owner check on every route with {id} (07-01): someone else's order → 404
API2 Broken authentication Accepting alg: none, hours-long tokens, password grant, JWKS without verifying aud jwtVerify with algorithms, issuer, audience; 5-minute tokens (07-01)
API3 Excessive data exposure / object property level GET /v1/orders/{id} returns the email and keycloakSub from the customers_ref replica; a PATCH that accepts status or total from the client Explicit DTOs (toDto/toRepresentation from 04-02/04-04): the outgoing fields are chosen; zod schemas with .strict() that reject unexpected fields
API4 Unrestricted resource consumption A client requests ?limit=100000, sends 50 MB bodies or makes 10,000 POST /v1/orders per minute limit max. 100 (03-01/04-02), express.json({ limit: '100kb' }), rate limit 300/min at the gateway (03-04), timeout and bulkhead (06-03)
API5 Broken function level authorization A customer calls GET /v1/orders?status=PENDING (operator listing) or POST /v1/products requireRole('operator') on operational routes (07-01); admin routes not exposed by the public gateway
API6 Unrestricted access to sensitive business flows A bot creates thousands of orders for a product on sale and drains the stock Rate limit per user (not only per IP), CAPTCHA on the web for spikes, business limits (max. N units per order/customer)
API7 SSRF A user-configurable webhook or an "image URL" that Catalog downloads points at http://10.0.0.5:15672 (RabbitMQ) or http://169.254.169.254 (cloud metadata) Do not accept arbitrary URLs from the user; if unavoidable, domain allowlist, resolve DNS and reject private IPs, do not follow redirects; restricted egress (07-04)
API8 Security misconfiguration CORS with *, x-powered-by, error stack traces sent to the client, NODE_ENV other than production CORS with specific origins (03-04), helmet (section 5), errorMiddleware that hides details (04-02)
API9 Improper inventory management A forgotten /v1/orders-legacy route without authentication; an old version still deployed OpenAPI as the source of truth (03-06), Pact contracts (04-05), the gateway as the single entry point, retiring versions with a date
API10 Unsafe consumption of third-party APIs Blindly trusting the response from the payment provider or from an address provider Also validate what arrives from third parties with zod (the productTranslator ACL from 04-04 does that), timeouts, HMAC on webhooks (07-02)

Most of the remedies already exist in TechCorp's code; this lesson gathers them and adds the missing ones.

  1. Input validation and sanitization with zod

Rule: everything that comes in over the network is validated against a schema before touching it, whether it comes from the user, from another service or from an event. In 04-02 and 04-04 this is already done with zod; here it is hardened:

// orders-service/src/routes/schemas.js
const { z } = require('zod');

// Identifiers with a closed pattern: no "ord-88213' OR 1=1" and no paths with "../"
const OrderId    = z.string().regex(/^ord-[0-9a-f]{8}$/, 'invalid orderId');
const CustomerId = z.string().regex(/^c-[0-9]{1,10}$/);
const ProductId  = z.string().regex(/^p-[0-9]{1,10}$/);
const COUNTRIES = ['ES', 'PT', 'FR'];                                      // allowlist, not "any ISO code"

const Address = z.object({
  street: z.string().trim().min(1).max(120),
  postalCode: z.string().regex(/^[0-9]{5}$/),
  city: z.string().trim().min(1).max(80),
  country: z.enum(COUNTRIES)
}).strict();                                                               // unknown fields → 400 (not silently ignored)

const CreateOrder = z.object({
  customerId: CustomerId,
  lines: z.array(z.object({ productId: ProductId, quantity: z.number().int().min(1).max(50) }).strict()).min(1).max(30),
  shippingAddress: Address
}).strict();

const ListOrders = z.object({
  status: z.enum(['PENDING', 'CONFIRMED', 'CANCELLED']).optional(),
  customerId: CustomerId.optional(),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  cursor: z.string().max(200).optional()
}).strict();

module.exports = { OrderId, CreateOrder, ListOrders };

And in the routes, the route parameters too, which many forget:

// orders-service/src/routes/orders.js (fragments)
router.get('/v1/orders/:id', requireScope('orders:read'), async (req, res, next) => {
  try {
    const id = OrderId.parse(req.params.id);           // ZodError → 400 INVALID_REQUEST via errorMiddleware (04-02)
    // ... owner (07-01), repository.get(id), ETag ...
  } catch (err) { next(err); }
});
router.post('/v1/orders', requireScope('orders:create'), async (req, res, next) => {
  try {
    const request = CreateOrder.parse(req.body);       // from here on, "request" is trusted in shape; the business decides the rest
    // ...
  } catch (err) { next(err); }
});

And in app.js the global limits the template already shipped with, now justified: express.json({ limit: '100kb', strict: true }) (a larger body → 413; strict rejects JSON that is not an object or array), and at the gateway proxy-body-size: 2m on the Ingress (05-02) as the absolute ceiling. Sanitization versus validation: TechCorp prefers to reject (400) rather than silently "clean up"; the only accepted normalization is trim() and uppercasing codes (country.toUpperCase()), and always in the schema, never scattered around.

  1. Injection: SQL, NoSQL, commands and logs

Injection happens when user data is interpreted as code or structure. Four variants that affect TechCorp:

SQL (PostgreSQL with pg). Never concatenate; always positional parameters, as OrderRepository already does in 04-04:

// BAD: if status = "PENDING' OR '1'='1", it returns everything; if it contains "; DROP TABLE orders; --", worse
await db.query(`SELECT * FROM orders.orders WHERE status = '${status}'`);
// GOOD: the value travels separately from the statement; the engine never interprets it as SQL
await db.query('SELECT * FROM orders.orders WHERE status = $1 AND customer_id = $2 ORDER BY created_at DESC LIMIT $3', [status, customerId, limit]);
// Identifiers (column name for ORDER BY) CANNOT be parameterized: allowlist
const SORT_COLUMNS = { createdAt: 'created_at', total: 'total_cents' };
const column = SORT_COLUMNS[sort] ?? 'created_at';

NoSQL (MongoDB in Catalog). The risk is different: not text, but objects. If a route does collection.find({ sku: req.query.sku }) and the client sends ?sku[$ne]=x, Express (with qs) builds { sku: { $ne: 'x' } } and the query returns every product; with $where or $regex it can be worse. Rule: never pass user objects into the filter; validate with zod that each value is a string (or number) and build the filter in the repository with fixed keys:

// catalog-service/src/routes/products.js
const Query = z.object({ ids: z.string().optional(), category: z.string().regex(/^[a-z-]{1,40}$/).optional(), limit: z.coerce.number().int().min(1).max(100).default(20) }).strict();
const q = Query.parse(req.query);                      // ?category[$ne]=x → fails: not a string
// repository: const filter = {}; if (q.category) filter.category = q.category;  // the key is set by the code, the value is a validated string

Also, express.json does not interpret operators, but a body { "filter": { "$where": "..." } } would indeed arrive as an object: same remedy (.strict() schema, no free-form filter).

System commands. They are almost never needed in a service; if they exist (generating a PDF, converting an image), execFile('convert', [path]) with arguments in an array, never exec(\convert ${name}`)`, and the file name validated with a pattern. Better still: a native library.

Logs. Log injection: a user puts the text "Gran Vía 12\n{\"level\":\"info\",\"message\":\"order paid\"}" in street and, if the log were plain text, a fake line would appear. With pino (06-01) the value goes serialized inside a JSON (newlines are escaped as \n), so the structure does not break; the rule is not to build messages by concatenating user input (log.info(\order from ${name}`)) but to pass it as a field (log.info({ customerId }, 'order created')`), and to remember that personal data never goes into the log under any circumstances (section 8).

  1. Security headers with helmet

helmet is a set of Express middlewares that sets defensive HTTP headers. At the gateway (what the browser sees) and in each service (defense in depth; and some, like the BFF, serve HTML):

// gateway/server.js and service template (app.js), after requestIdMiddleware
const helmet = require('helmet');
app.use(helmet({
  contentSecurityPolicy: false,          // the API does not serve HTML; the CSP is defined by the SPA/BFF that does
  strictTransportSecurity: { maxAge: 31_536_000, includeSubDomains: true },   // HSTS (07-02): consistent with the Ingress
  crossOriginResourcePolicy: { policy: 'same-site' }
}));
Header Effect Who needs it
Strict-Transport-Security HTTPS only for a year Gateway (and the Ingress, 07-02)
X-Content-Type-Options: nosniff The browser does not "guess" MIME types (a JSON response is not executed as a script) Everyone
X-Frame-Options: DENY / CSP frame-ancestors Prevents clickjacking BFF/web; harmless in APIs
Content-Security-Policy Which script/style origins are allowed web-store and bff-mobile if they serve HTML; disabled in the API
Referrer-Policy, X-DNS-Prefetch-Control, Cross-Origin-* Information leaks while browsing Web
(removes) X-Powered-By Do not advertise Express Everyone (app.disable('x-powered-by') already in 04-02)

helmet does not replace CORS (03-04): CORS says from which origin the browser may call; helmet says how the browser must treat the response.

  1. Dependency management

A typical orders-service drags along 300 transitive packages; most real vulnerabilities come in through there. TechCorp's policy:

Practice How When
Versioned package-lock.json and npm ci The lockfile pins exact versions; npm ci fails if it does not match package.json (05-01, 05-03) Always
npm audit --omit=dev --audit-level=high The pipeline fails if there is a high/critical vulnerability with a patch in production dependencies In ci.yml, lint stage
Dependabot or Renovate Automatic update pull requests, grouped weekly; security patches, immediately Configuration in .github/dependabot.yml
Update policy Minors and patches: accepted if CI and contracts pass; majors: team review Luis's rule (05-03)
Minimal dependencies Before adding a package: does Node ship it? (fetch, crypto, AbortSignal), is it maintained?, how many transitives does it drag in? PR review
Pin the base image and CI actions node:20-alpine with digest (07-04), actions/checkout@v4 by version Template
SBOM Bill of materials for every image (Syft, 07-04) to know within minutes whether "we use library X" Mention; generated in CI

And npm audit in CI is not infallible: review the advisory, do not silence it with --force; if there is no patch, assess the risk (is the vulnerable function used?) and document the exception with a review date.

  1. Secrets in code: never, and how to make sure

The rule has existed since 04-03 (secrets arrive through the environment or _FILE files, never in the repository or the image); here it becomes verifiable:

# .github/workflows/node-service-ci.yml (fragment, the reusable workflow from 05-03)
      - name: Scan history for secrets
        uses: gitleaks/gitleaks-action@v2          # detects AWS keys, tokens, passwords in URLs, private keys...
        env: { GITLEAKS_LICENSE: "" }               # free for organization repos with a license; alternative: git-secrets or trufflehog

And locally, a pre-commit hook with gitleaks protect --staged. If a secret reaches the repository, the only correct response is to rotate it (git history is public forever to anyone who has cloned it): the mechanics of rotation — Secrets, External Secrets, restarts — belong to 07-04. Add .env, *.pem, *.key to the template's .gitignore; and remember that a secret in a console.log, in an error trace or in a request URL (?token=) is also a leak: hence redact (06-01) and configForLog() (04-03).

  1. Protecting personal data: GDPR in practice

TechCorp processes Ana's personal data (name, email, address) and payment data. Without pretending to replace the data protection officer, the technical decisions a team must make:

  • Minimization. Should order.created carry customer.email and name (02-02)? They are needed so that Notifications can send the email without calling Customers (autonomy, 03-02). Alternatives: (a) the event carries the data and all consumers receive it (Inventory does not need it); (b) the event carries only customerId and Notifications queries GET /v1/customers/{id} with its service token. TechCorp's decision: (a) for autonomy and because Notifications is the only subscriber that uses it today, under three conditions: personal data goes only in order.created (not in the rest of the saga's events), RabbitMQ is encrypted (07-02) and the queues and the DLQ have limited retention (messages in .dlq older than 7 days are purged after review: they are not an archive); and it will be revisited toward (b) if a second consumer appears that does not need it.
  • Pseudonymization. Outside Customers, services store customerId, not the name; logs and metrics carry only identifiers (06-01); Jaeger traces carry no bodies.
  • Encryption at rest. On managed databases it is enabled by default (encrypted disks); for especially sensitive columns you can encrypt at the application level with a key from the secrets manager, at the cost of not being able to index them. Backups encrypted and with the same retention.
  • Right to erasure. The flow from 02-04: customers-service anonymizes and publishes customer.deleted; Orders deletes customers_ref and anonymizes addresses of old orders that tax regulations require keeping; Notifications stores nothing. It is one more use case, with an automated test.
  • Card data: NEVER at TechCorp. The browser sends the card directly to the payment provider (the provider's form or SDK); TechCorp receives a token (tok_…) that payments-service uses to charge. That way the PCI DSS scope is reduced to the minimum (SAQ A). Neither the number, nor the CVV, nor even "the last four digits" unless the provider returns them already masked. *.card in redact is a safety net in case someone slips up, not a permission.
  • Record of processing activities and retention: each service documents which personal data it stores, why and for how long (table in its README), and a CronJob applies the retention (e.g. anonymizing addresses of orders older than 5 years).

  1. Auditing sensitive actions

The logs from 06-01 tell what happened technically; the audit trail tells who did what to what, and must be provable months later. Auditable actions at TechCorp: cancelling an order (who, reason), changing a price in Catalog, manually changing a payment's status, reprocessing the DLQ (scripts/reprocessDlq.js, 06-03), an operator accessing a customer's data, changing roles in Keycloak. Requirements: immutable (insert-only, no UPDATE/DELETE for the svc_* user), separate from operational logs (different retention: years versus weeks), and correlatable with X-Request-Id and the token's sub (X-User-Id, 07-01).

// @techcorp/common-http/src/audit.js — one record per sensitive action
function createAudit({ db, service }) {
  return {
    async record(req, { action, resource, resourceId, detail = {} }) {
      // table <schema>.audit_log(id, occurred_at, service, action, resource, resource_id, user_id, roles, request_id, detail jsonb)
      // The svc_* user only has INSERT on it; reading is a separate role (auditor)
      await db.query(
        'INSERT INTO audit_log (occurred_at, service, action, resource, resource_id, user_id, roles, request_id, detail) VALUES (now(), $1, $2, $3, $4, $5, $6, $7, $8)',
        [service, action, resource, resourceId, req.user?.sub ?? 'system', (req.user?.roles ?? []).join(','), req.id, JSON.stringify(detail)]
      );
    }
  };
}
// Usage in the cancellation (03-01): await audit.record(req, { action: 'ORDER_CANCELLED', resource: 'order', resourceId: order.orderId, detail: { reason } });

At high volume, the table is replaced by audit.* events sent to a write-only store (or the structured log with type: 'audit' sent to a Loki tenant with long retention and no deletion); what matters is the separation and immutability, not the technology. detail contains no personal data: identifiers and business values.

  1. Security testing: SAST, DAST, review and pentest

Type What it does Tool at TechCorp When
SAST (static analysis) Looks for dangerous patterns in the code without running it eslint-plugin-security in the template's configuration; Semgrep with the p/nodejs and p/owasp-top-ten rules in CI Every PR
Dependency analysis Known vulnerabilities npm audit, Dependabot (section 6) Every PR and daily
Image scanning CVEs in the image Trivy (07-04) Every build
DAST (dynamic) Attacks the deployed API OWASP ZAP in API mode (zap-api-scan.py with the OpenAPI from 03-06) against staging, after the E2E from 05-03 Every deployment to staging (report), weekly (blocking on high)
Code review Human eyes with a checklist Checklist from section 12 in the PR template; reviewer from another team for routes with {id} or personal data Every PR
Pentest An external professional tries to break it External firm; scope: gateway, web-store, Keycloak, webhooks Annual and before the Payments audit
# .github/workflows/node-service-ci.yml (SAST fragment)
      - name: Semgrep
        uses: semgrep/semgrep-action@v1
        with: { config: "p/nodejs p/owasp-top-ten p/secrets" }

And application security tests are ordinary tests: the test/orders.auth.test.js from 07-01 (401/403/404) and cases such as "an id that does not match the pattern returns 400", "a body with an unknown field returns 400", "?category[$ne]=x returns 400" live in Jest alongside the rest and protect against regressions.

  1. Vulnerability response

Sooner or later someone will find something. What TechCorp leaves prepared:

  1. Reporting channel: [email protected] and a /.well-known/security.txt file on shop.techcorp.example (RFC 9116) with contact and policy; public acknowledgment (no formal reward in phase 1).
  2. Triage: severity with CVSS; critical/high → security incident following the process from 06-05 (channel, owner, timeline), even if there is no service outage.
  3. Patching: branch from main, a test that reproduces the flaw, deployment through the normal pipeline (canary if applicable, 05-04); vulnerable dependency → update or temporary mitigation (block the route at the gateway).
  4. Communication: internal (all teams, because the same pattern may exist in another service) and, if personal data was accessed, notification to the supervisory authority within 72 hours and to those affected under the GDPR: a decision of the data protection officer, not of the technical team.
  5. Blameless postmortem (06-05) with actions: new Semgrep rule, new item on the checklist, regression test.

  1. Per-service security checklist

This table is added to techcorp/node-service-template/SECURITY.md and to the pull request template; each service reviews it when created and on every relevant change:

Area Check Reference
Authentication authenticate() on /v1/*; probes and /metrics without token; algorithms, issuer, audience pinned 07-01
Authorization Every route with {id} checks owner or role; requireScope/requireRole on operational routes 07-01, API1/API5
Input zod .strict() schemas for body, query and route parameters; ids with a pattern; limit ≤ 100; express.json({ limit }) Section 3, API4
Output Explicit DTOs; never third parties' email/keycloakSub; errors without stack traces Section 2 (API3), 04-02
Injection Parameterized queries; MongoDB filters with fixed keys; no exec with user input; logging by fields Section 4
Headers helmet, x-powered-by off, CORS with specific origins Section 5
Dependencies npm ci, npm audit in CI, Dependabot enabled, no unmaintained packages Section 6
Secrets None in repo/image/log; gitleaks in CI; redact and configForLog() cover the new ones Section 7, 07-04
Personal data Processing table in the README; retention with a CronJob; reacts to customer.deleted if it stores customer data; no card data whatsoever Section 8
Audit Sensitive actions recorded with user_id and request_id; insert-only table Section 9
Tests 400/401/403/404 cases in Jest; Semgrep and ZAP green or with documented exceptions Section 10
Channel TLS to DB and broker, minimal svc_* user 07-02
Platform securityContext, NetworkPolicy, own ServiceAccount 07-04

Common Mistakes and Tips

  • Validating only the body and leaving req.params.id and req.query without a schema: half of the injections and BOLA come in through there.
  • Permissive schemas (z.object without .strict(), z.string() without max) that accept any field or 10 MB strings: validation must be as strict as the OpenAPI contract.
  • "Sanitizing" instead of rejecting: stripping "dangerous" characters with home-grown regular expressions never covers every case and silently changes the user's data.
  • Trusting third-party responses because "it's the payment provider": API10 exists because third parties make mistakes too, and get attacked too.
  • Logging the request body at info "for debugging" and discovering weeks later that Loki is storing addresses and emails.
  • Silencing npm audit with --force or audit-level=critical so that CI passes: every exception, documented with a date.
  • Treating the audit trail as "just another log" and deleting it after 30 days with Loki's retention.
  • Tip: every rule in this lesson that can be automated (lint, Semgrep, gitleaks, ZAP, Jest test) is worth more than the same rule in a document; the checklist is for what cannot be automated.

Exercises

Exercise 1. A developer proposes adding to catalog-service the route GET /v1/products/search?filter=<json>, which receives a MongoDB filter object "so the front end can run flexible queries". Explain the problem using the OWASP numbering and propose a secure design that covers the real needs (search by text, category and price range).

Exercise 2. Write the zod schema for POST /v1/orders/{id}/cancellation (03-01: reason from an allowed list, optional comment) including the route parameter, and the corresponding audit record with createAudit.

Exercise 3. Marta asks whether, to comply with the GDPR, it is enough for Notifications to "delete the email when customer.deleted arrives". List which other places in the platform may contain Ana's email and which measure applies to each.

Solutions

Solution 1. It is a textbook API8/API3 and NoSQL injection: an arbitrary filter object allows { "$where": "..." }, { "price": { "$gt": 0 } } on fields that should not be filterable, exposing internal fields (supplierCost), unindexed queries that bring Mongo down (API4) and, with $lookup in aggregations, reading other collections. Secure design: named parameters and a closed schema, Search = z.object({ q: z.string().trim().min(2).max(60).optional(), category: z.string().regex(/^[a-z-]{1,40}$/).optional(), priceMin: z.coerce.number().min(0).optional(), priceMax: z.coerce.number().max(100000).optional(), limit: ...max 100, cursor }).strict(); the repository builds the filter with fixed keys ({ normalizedName: { $regex: escape(q), $options: 'i' } } with q escaped or, better, a text index and $text: { $search: q }; priceCents: { $gte, $lte }); indexed fields only; and the route returns the usual toDto DTO. If the front end needs something more flexible, that is a case for GraphQL in the BFF (03-03), with a typed schema, not for passing Mongo objects around.

Solution 2.

const REASONS = ['CUSTOMER_CHANGED_MIND', 'ORDER_ERROR', 'OUT_OF_STOCK', 'FRAUD'];
const Cancellation = z.object({ reason: z.enum(REASONS), comment: z.string().trim().max(300).optional() }).strict();

router.post('/v1/orders/:id/cancellation', requireScope('orders:create'), async (req, res, next) => {
  try {
    const id = OrderId.parse(req.params.id);
    const { reason, comment } = Cancellation.parse(req.body);
    const order = await repository.get(id);
    // owner/role as in 07-01 (customer: only their own → otherwise 404; operator: any; FRAUD only operator)
    if (reason === 'FRAUD' && !req.user.roles.includes('operator')) throw new BusinessError('FORBIDDEN', 'Reason reserved for operators', 403);
    await cancelOrder({ order, reason, user: req.user });   // transition + outbox order.cancelled (04-04)
    await audit.record(req, { action: 'ORDER_CANCELLED', resource: 'order', resourceId: id, detail: { reason, hasComment: Boolean(comment) } });
    res.status(202).set('Location', `/v1/orders/${id}`).end();
  } catch (err) { next(err); }
});

The comment does not go into the audit trail (free text from the user, may contain personal data): only whether it exists. And the audit record is written after the transition, inside the same transaction if the repository allows it, so as not to audit cancellations that failed.

Solution 3. Places and measures: (1) Customers database: anonymize the row (not delete, for integrity and tax obligations), source of the event. (2) customers_ref in Orders (02-04): delete the row and anonymize shipping_address of old orders on receiving customer.deleted. (3) order.created messages in RabbitMQ queues, .retry and .dlq: short retention and purge (section 8); if there are DLQ messages for the customer, they are processed or discarded. (4) Logs in Loki: there should be no email thanks to redact and the rule of not logging it; if an audit finds one, it is an incident (section 11) and it must be deleted from the store. (5) Traces in Jaeger: no bodies or personal data by design (06-02); check custom span attributes. (6) Backups: they are not edited; it is documented that the data disappears when the backup's retention expires (and it is anonymized on restore). (7) Keycloak: delete or disable the user (the sub), which is where the login email lives. (8) Notifications' email provider (if it keeps a sending history): configure minimal retention or request deletion through its API. (9) The audit table: it must not contain emails (only user_id), and by design it is kept. The answer to Marta: "delete in Notifications" is one of nine actions, which is why the right to erasure is a use case with a list and a test, not a DELETE.

Conclusion

This lesson has turned "code" security into concrete TechCorp practices: six principles (least privilege, defense in depth, fail secure, minimal surface, security by design, shift left); the OWASP API Security Top 10 read through its own cases and remedies that already existed (resource owner, DTOs, limit ≤ 100, rate limit, CORS, OpenAPI and Pact) or have been added; zod .strict() schemas with pattern-based ids (^ord-[0-9a-f]{8}$), allowlists and limits, also on route and query parameters, with express.json({ limit: '100kb' }); parameterized queries in pg, MongoDB filters with fixed keys, execFile and logging by fields; helmet on gateway and services; npm ci, npm audit, Dependabot and SBOM; gitleaks in CI and rotation on any leak; practical GDPR (minimization with the decision to keep email in order.created under conditions, pseudonymization, encryption at rest, customer.deleted, retention, and never card data: provider tokenization and minimal PCI DSS scope); an immutable, separate audit trail with createAudit correlated by user_id and request_id; SAST with ESLint/Semgrep, DAST with ZAP against staging, checklist review and an annual pentest; a response process with security.txt and GDPR notification; and the checklist that is now part of node-service-template. The last layer remains, the one that holds up all the others: the image that runs the code, the pod that contains it, the secrets that reach it, the network around it and the cluster's permissions. That is the topic of the next lesson: container and Kubernetes security.

Microservices Course

Module 1: Introduction to Microservices

Module 2: Microservice Design

Module 3: Communication between Microservices

Module 4: Implementing Microservices

Module 5: Deployment and Orchestration

Module 6: Monitoring and Maintenance

Module 7: Security in Microservices

Module 8: Case Studies and Practical Examples

© Copyright 2026. All rights reserved