05 No secrets in logs 6 chapters

Theme 04 · Debuggability-Focused

No secrets in logs

Explanation

No secrets in logs

Plain Human Explanation

Logs are copied, searched, retained, exported, and pasted into support threads. Anything secret that enters logs can spread farther than the original system.

Good debugging does not require dumping passwords, tokens, cookies, payment details, or full request bodies. It requires choosing the few safe fields that explain what happened.

Technical Explanation

Treat logging as an allowlist, not a dump. Build log entries from safe fields such as ids, status codes, stable error codes, and coarse counts. Redact or omit secret-bearing fields before they reach the logger.

In TypeScript, this often means using a SafeLogFields type, a redaction function at the boundary, or a small log helper that refuses unknown raw payloads.

Why It Matters

  • User impact: leaked credentials or private data can harm users long after the original bug is fixed.
  • Product behavior: support and engineering still get useful incident context without exposing secrets.
  • Risk: logs often have broader access, longer retention, and weaker deletion paths than product data.
  • Decision point: when a value authenticates a user, identifies private content, or can be used to impersonate someone, it must not be logged.

The Core Move

Log the question you need answered, not the object you happen to have. Prefer a small safe summary over a complete raw payload.

Small Example

No secrets in logs: Small Example

Bad TypeScript Example

type LoginRequest = {
  email: string;
  password: string;
  rememberMe: boolean;
};

type Logger = {
  info(event: string, fields: Record<string, unknown>): void;
};

export function logLoginAttempt(request: LoginRequest, logger: Logger) {
  logger.info("login.attempted", request);
}
type LoginRequest = {
  email: string;
  password: string;
  rememberMe: boolean;
};

type Logger = {
  info(
    event: string,
    fields: Record<string, unknown>,
  ): void;
};

export function logLoginAttempt(
  request: LoginRequest,
  logger: Logger,
) {
  logger.info("login.attempted", request);
}

Good TypeScript Example

type LoginRequest = {
  email: string;
  password: string;
  rememberMe: boolean;
};

type Logger = {
  info(event: string, fields: Record<string, unknown>): void;
};

export function logLoginAttempt(request: LoginRequest, logger: Logger) {
  logger.info("login.attempted", {
    emailDomain: request.email.split("@").at(1) ?? "unknown",
    rememberMe: request.rememberMe,
  });
}
type LoginRequest = {
  email: string;
  password: string;
  rememberMe: boolean;
};

type Logger = {
  info(
    event: string,
    fields: Record<string, unknown>,
  ): void;
};

export function logLoginAttempt(
  request: LoginRequest,
  logger: Logger,
) {
  logger.info("login.attempted", {
    emailDomain:
      request.email.split("@").at(1) ??
      "unknown",
    rememberMe: request.rememberMe,
  });
}

What Changed

  • The bad version logs the password because it passes the whole request through.
  • The good version logs only the fields needed to understand the login attempt.
  • The email is reduced to a domain, which is enough for coarse debugging without storing the full address.

Realistic Example

No secrets in logs: Realistic Example

This example handles an OAuth callback. The team needs enough context to debug provider failures without recording access tokens.

Bad TypeScript Example

export async function oauthCallback(req: any, auth: any, logger: any) {
  logger.info("oauth.callback.received", { query: req.query, cookies: req.cookies });
  const session = await auth.exchangeCode(req.query.code, req.cookies.oauth_state);
  logger.info("oauth.callback.completed", { session });
  return session;
}
export async function oauthCallback(
  req: any,
  auth: any,
  logger: any,
) {
  logger.info("oauth.callback.received", {
    query: req.query,
    cookies: req.cookies,
  });

  const session = await auth.exchangeCode(
    req.query.code,
    req.cookies.oauth_state,
  );

  logger.info("oauth.callback.completed", {
    session,
  });

  return session;
}

Good TypeScript Example

type OAuthCallback = {
  provider: "github" | "google";
  code: string;
  state: string;
};

type AuthSession = {
  userId: string;
  expiresAt: Date;
};

type AuthService = {
  exchangeCode(callback: OAuthCallback): Promise<AuthSession>;
};

type Logger = {
  info(event: string, fields: Record<string, unknown>): void;
  error(event: string, fields: Record<string, unknown>): void;
};

function stateFingerprint(state: string): string {
  return `${state.length}:${state.slice(0, 4)}`;
}

export async function oauthCallback(
  callback: OAuthCallback,
  auth: AuthService,
  logger: Logger,
) {
  logger.info("oauth.callback.received", {
    provider: callback.provider,
    stateFingerprint: stateFingerprint(callback.state),
  });

  const session = await auth.exchangeCode(callback);

  logger.info("oauth.callback.completed", {
    provider: callback.provider,
    userId: session.userId,
    expiresAt: session.expiresAt.toISOString(),
  });

  return session;
}
type OAuthCallback = {
  provider: "github" | "google";
  code: string;
  state: string;
};

type AuthSession = {
  userId: string;
  expiresAt: Date;
};

type AuthService = {
  exchangeCode(
    callback: OAuthCallback,
  ): Promise<AuthSession>;
};

type Logger = {
  info(
    event: string,
    fields: Record<string, unknown>,
  ): void;
  error(
    event: string,
    fields: Record<string, unknown>,
  ): void;
};

function stateFingerprint(
  state: string,
): string {
  return `${state.length}:${state.slice(0, 4)}`;
}

export async function oauthCallback(
  callback: OAuthCallback,
  auth: AuthService,
  logger: Logger,
) {
  logger.info("oauth.callback.received", {
    provider: callback.provider,
    stateFingerprint: stateFingerprint(
      callback.state,
    ),
  });

  const session =
    await auth.exchangeCode(callback);

  logger.info("oauth.callback.completed", {
    provider: callback.provider,
    userId: session.userId,
    expiresAt:
      session.expiresAt.toISOString(),
  });

  return session;
}

What Changed

  • The bad version logs authorization codes, cookies, and the full session object.
  • The good version logs provider and user id but keeps token-bearing values out of logs.
  • A small fingerprint helps correlate state-related issues without storing the state secret itself.

System Example

No secrets in logs: System Example

At system scale, secret-safe logging needs to survive helper calls, retries, and third-party responses. The workflow should choose safe fields once and pass those to the logger.

Larger System-Level Bad TypeScript Example

export async function syncCrmContact(job: any, crm: any, logger: any) {
  logger.info("crm.sync.started", { job });

  try {
    const response = await crm.upsertContact(job.data);
    logger.info("crm.sync.finished", { job, response });
  } catch (error) {
    logger.error("crm.sync.failed", { job, error });
    throw error;
  }
}
export async function syncCrmContact(
  job: any,
  crm: any,
  logger: any,
) {
  logger.info("crm.sync.started", {
    job,
  });

  try {
    const response =
      await crm.upsertContact(job.data);

    logger.info("crm.sync.finished", {
      job,
      response,
    });
  } catch (error) {
    logger.error("crm.sync.failed", {
      job,
      error,
    });

    throw error;
  }
}

Larger System-Level Good TypeScript Example

type CrmSyncJob = {
  id: string;
  data: {
    userId: string;
    email: string;
    accessToken: string;
    changedFields: Array<"name" | "company" | "plan">;
  };
};

type CrmResponse = {
  contactId: string;
  status: "created" | "updated";
};

type CrmClient = {
  upsertContact(data: CrmSyncJob["data"]): Promise<CrmResponse>;
};

type Logger = {
  info(event: string, fields: Record<string, unknown>): void;
  error(event: string, fields: Record<string, unknown>): void;
};

function safeCrmLogFields(job: CrmSyncJob) {
  return {
    jobId: job.id,
    userId: job.data.userId,
    emailDomain: job.data.email.split("@").at(1) ?? "unknown",
    changedFields: job.data.changedFields,
  };
}

export async function syncCrmContact(job: CrmSyncJob, crm: CrmClient, logger: Logger) {
  const safeFields = safeCrmLogFields(job);
  logger.info("crm.sync.started", safeFields);

  try {
    const response = await crm.upsertContact(job.data);
    logger.info("crm.sync.finished", {
      ...safeFields,
      contactId: response.contactId,
      status: response.status,
    });
  } catch (error) {
    logger.error("crm.sync.failed", { ...safeFields, error });
    throw error;
  }
}
type CrmSyncJob = {
  id: string;
  data: {
    userId: string;
    email: string;
    accessToken: string;
    changedFields: Array<
      "name" | "company" | "plan"
    >;
  };
};

type CrmResponse = {
  contactId: string;
  status: "created" | "updated";
};

type CrmClient = {
  upsertContact(
    data: CrmSyncJob["data"],
  ): Promise<CrmResponse>;
};

type Logger = {
  info(
    event: string,
    fields: Record<string, unknown>,
  ): void;
  error(
    event: string,
    fields: Record<string, unknown>,
  ): void;
};

function safeCrmLogFields(job: CrmSyncJob) {
  return {
    jobId: job.id,
    userId: job.data.userId,
    emailDomain:
      job.data.email.split("@").at(1) ??
      "unknown",
    changedFields: job.data.changedFields,
  };
}

export async function syncCrmContact(
  job: CrmSyncJob,
  crm: CrmClient,
  logger: Logger,
) {
  const safeFields = safeCrmLogFields(job);

  logger.info(
    "crm.sync.started",
    safeFields,
  );

  try {
    const response =
      await crm.upsertContact(job.data);

    logger.info("crm.sync.finished", {
      ...safeFields,
      contactId: response.contactId,
      status: response.status,
    });
  } catch (error) {
    logger.error("crm.sync.failed", {
      ...safeFields,
      error,
    });

    throw error;
  }
}

What Changed

  • The bad worker logs the full job, including an access token and user email.
  • The good worker creates one safe field set and reuses it for start, success, and failure logs.
  • The CRM call still receives the data it needs, but the logger receives only the debugging summary.

When To Use It

No secrets in logs: When To Use It

Use This When

  • Code handles passwords, session cookies, API keys, OAuth codes, access tokens, payment details, or private user content.
  • Logs are shipped to a third-party provider, data warehouse, shared dashboard, or support tooling.
  • A team is tempted to log a full request, job, provider response, or error object to speed up debugging.

Avoid This When

  • Avoid redacting after the secret is already logged; prevent it from reaching the logger.
  • Avoid hiding every useful field. Safe ids, stable codes, status values, and coarse counts are often enough.
  • Avoid one-off redaction rules that differ across call sites for the same payload.

Tradeoffs

Secret-safe logging takes a little discipline because raw dumps are convenient during development. The payoff is that logs remain usable in production without becoming a second, riskier copy of private data.

  • Structured errors
  • Safe telemetry
  • Tracing

Practice Prompt

No secrets in logs: Practice Prompt

Beginner Exercise

Find one log call that passes a full request, job, or provider response. Replace it with a small object of safe fields.

Intermediate Exercise

Write a safeLogFields function for one secret-bearing payload. Use it in success and failure logs so both paths stay consistent.

Stretch Exercise

Add a test that proves a log entry does not include password, token, cookie, authorization code, or raw email fields.

Reflection Question

What exact debugging question does this log entry answer, and which fields would create more risk than value?

Suggest an edit

Leave a private editorial note. This creates a GitHub issue for this curriculum page.