Part of Data sovereigntyLaw 25 · Engineering

Law 25 for developers: what the Act asks of your code.

Law 25 turns into concrete engineering work. Privacy settings start at the most private value (section 9.1). Forms collect only what a stated purpose needs (section 5). Data is destroyed when the purpose is done (section 23), exported in a structured format on request (section 27), and every confidentiality incident goes in a register (section 3.8).

Facts checked

The Act, section by section, as engineering work

Section numbers are from Quebec’s Act respecting the protection of personal information in the private sector, as amended by Law 25, read on LégisQuébec on 2026-10-29. The design of each item is ours: the Act sets the obligation, not the schema. This is not legal advice.

SectionWhat it says, in shortWhat you build
9.1 †Privacy settings give “the highest level of confidentiality by default”Column defaults set to the private value, and a test that checks them
4, 5, 9 †Decide the purposes first; collect only what they need; in doubt, information is not necessaryA strict input schema with a purpose per field
12, 14 †Use only for the collected purpose; consent is specific, per purpose, and lasts only as long as neededA consent table keyed by purpose and wording version
23, 3.2 †Destroy or anonymize when the purpose is achieved; a policy frames keeping and destructionA scheduled purge, and backup retention short enough to finish the job
27, 3.3 †Give people what they provided “in a structured, commonly used technological format”An export endpoint that returns JSON
3.5–3.8 †Reduce risk, notify if serious, keep a register of confidentiality incidentsAn incidents table and the habit of filling it
10, 5 †Security measures reasonable for the sensitivity; collect only what is neededLogs that hold IDs, not names, emails or bodies
17 †Assess before personal information leaves Quebec, including to a provider that keeps itA data map of every place a copy lives

† Competitor details come from each vendor’s own published pages on the dates shown; prices and plans change, so check the vendor before you decide. 9.1 (2026-10-29) · 4, 5, 9 (2026-10-29) · 12, 14 (2026-10-29) · 23, 3.2 (2026-10-29) · 27, 3.3 (2026-10-29) · 3.5–3.8 (2026-10-29) · 10, 5 (2026-10-29) · 17 (2026-10-29)

Confidentiality by default (section 9.1)

If your product has privacy settings, they must provide “the highest level of confidentiality by default, without any intervention by the person concerned.” In code, that is a column default. A profile that starts public, or a sharing flag that starts on, fails on day one for every user who never opens the settings page.

Section 9.1 excludes browser cookie settings; those are handled elsewhere. Section 8.1 adds a related rule: if you use technology that can identify, locate or profile someone, you must tell them first and tell them how to activate those functions. Build location and profiling as opt-in.

Put the defaults in the database, not only in the form, so an API client or a seed script gets the same answer. Then test it:

CREATE TABLE privacy_settings (
  user_id          bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
  profile_public   boolean NOT NULL DEFAULT false,
  show_in_search   boolean NOT NULL DEFAULT false,
  share_location   boolean NOT NULL DEFAULT false,  -- s. 8.1: opt-in
  personalized_ads boolean NOT NULL DEFAULT false   -- profiling: opt-in
);

-- test: a new user gets the most private settings without touching anything
test("new accounts start private (s. 9.1)", async () => {
  const user = await createUser({ email: "a@example.com" });
  const s = await db.one("SELECT * FROM privacy_settings WHERE user_id = $1", [user.id]);
  expect(s).toMatchObject({
    profile_public: false, show_in_search: false,
    share_location: false, personalized_ads: false,
  });
});

Collect only what the purpose needs (sections 4, 5 and 9)

Section 4 says you determine the purposes before collecting. Section 5 says you collect “only the information necessary for the purposes determined”. Section 9 says you cannot refuse a service because someone declines to give information the service doesn’t need, and “in case of doubt, personal information is deemed to be non-necessary.”

Write the purpose next to each field, make anything not strictly needed optional, and reject fields you didn’t ask for instead of saving them. A strict schema does all three:

import { z } from "zod";

export const Signup = z.object({
  email: z.string().email(),            // purpose: sign-in, account notices
  name:  z.string().min(1).max(100),    // purpose: invoices
  phone: z.string().max(30).optional(), // not needed for the service: never required (s. 9)
  // no birth date, no address: nothing we do needs them (s. 5)
}).strict();                             // unknown fields are rejected, not stored

Section 12 limits use to the purposes the information was collected for, with exceptions such as a consistent purpose or fraud prevention; commercial prospection is never a consistent purpose. Section 14 says consent must be “given for specific purposes”, “requested for each such purpose”, presented separately when written, and is “valid only for the time necessary”. Section 8 gives people the right to withdraw it.

One boolean called marketing_ok can’t show any of that. Record each purpose separately, with the wording the person saw and when, and check the record before each use:

CREATE TABLE consents (
  id            bigserial PRIMARY KEY,
  user_id       bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  purpose       text   NOT NULL,       -- 'newsletter', 'share_with_partner_x'
  text_version  text   NOT NULL,       -- which wording they agreed to
  granted_at    timestamptz NOT NULL DEFAULT now(),
  expires_at    timestamptz,           -- s. 14: only as long as the purpose needs
  withdrawn_at  timestamptz            -- s. 8: withdrawal is a row update, not a delete
);

-- before sending the newsletter
SELECT u.email
FROM users u
JOIN consents c ON c.user_id = u.id
WHERE c.purpose = 'newsletter'
  AND c.withdrawn_at IS NULL
  AND (c.expires_at IS NULL OR c.expires_at > now());

Retention and destruction (sections 23 and 3.2)

Section 23: when the purposes are achieved, you “must destroy the information, or anonymize it”, subject to any retention period another Act sets (tax records, for example). Section 3.2 requires governance policies that frame “the keeping and destruction of the information”. Write the period in the policy, then make a job enforce it.

Anonymized has a strict meaning in section 23: it must be reasonably foreseeable that the information “irreversibly no longer allows the person to be identified directly or indirectly”, done to criteria set by regulation. Replacing an email with its SHA-256 hash is not that: anyone holding the email can compute the same hash. When in doubt, delete.

Backups hold deleted rows until the backup itself expires, so backup retention is part of your destruction period. On Canner you set both the interval and how long each backup is kept (canner db backup-policy <name> --enable --every 24 --keep 7), each backup is encrypted with its own key, and deleting a backup destroys that key at once. A nightly scheduled query can run the purge itself:

-- nightly; retention policy: 24 months after last sign-in, unless on legal hold
DELETE FROM users
WHERE last_seen_at < now() - interval '24 months'
  AND legal_hold = false;
-- child rows (privacy_settings, consents, orders.user_id) use ON DELETE CASCADE
-- or are kept with user_id set to NULL where another Act requires the record

Exports in a structured format (sections 27 and 3.3)

Section 27 gives a person the right to a copy of their information. Its third paragraph, in force since September 2024, adds that computerized information “collected from the applicant, and not created or inferred” from it must, on request, be given “in a structured, commonly used technological format”, unless that raises serious practical difficulties. Section 3.3 requires new systems to be built so this is possible. Section 32 gives you 30 days to reply.

The Act names no format. JSON or CSV are the obvious choices. Note the scope: what the person gave you (profile, messages, uploads, orders), not your inferred scores or segments. Those still fall under the general right of access in the first paragraph, but not under the portability format rule.

app.get("/account/export", requireUser, async (req, res) => {
  const id = req.user.id;
  const [profile, orders, consents] = await Promise.all([
    db.one("SELECT email, name, phone, created_at FROM users WHERE id = $1", [id]),
    db.any("SELECT id, placed_at, items, total FROM orders WHERE user_id = $1", [id]),
    db.any("SELECT purpose, text_version, granted_at, withdrawn_at FROM consents WHERE user_id = $1", [id]),
  ]);
  res.setHeader("Content-Disposition", 'attachment; filename="my-data.json"');
  res.json({ exported_at: new Date().toISOString(), profile, orders, consents });
});

An incident register (sections 3.5 to 3.8)

Section 3.6 defines a confidentiality incident: access, use or communication “not authorized by law”, or “loss of personal information or any other breach of the protection of such information.” A support agent opening a record they had no reason to see counts. So does a bucket left public.

Section 3.5 requires reasonable measures to reduce the risk, and, if there is “a risk of serious injury”, prompt notice to the Commission d’accès à l’information and to the people concerned. Section 3.8 requires a register of every incident, serious or not, and a copy goes to the Commission on request. A government regulation may set the register’s content; check it before you settle the columns. A table is a reasonable start:

CREATE TABLE confidentiality_incidents (
  id                 bigserial PRIMARY KEY,
  detected_at        timestamptz NOT NULL,
  occurred_from      timestamptz,
  occurred_to        timestamptz,
  description        text NOT NULL,         -- what happened, how it was found
  info_types         text[] NOT NULL,       -- '{email,order_history}'
  persons_affected   integer,
  serious_injury_risk boolean NOT NULL,     -- s. 3.7: sensitivity, consequences, likelihood
  cai_notified_at    timestamptz,           -- s. 3.5, if serious
  persons_notified_at timestamptz,
  measures_taken     text NOT NULL,
  recorded_by        text NOT NULL
);

Logs without personal information (sections 5 and 10)

Logs are personal information that you keep, often for longer and with wider access than the database. Section 10 asks for security measures reasonable for the sensitivity and quantity of the information; section 5 asks you to collect only what you need. Log a user ID, not the name or email, and never log request bodies or authorization headers.

Keep personal information out of URLs too: a query string such as ?email= ends up in every access log along the way. On Canner, the request log records method, path, status and timing, and stores no client IP addresses, headers or bodies. Before anything is written, credential-shaped values, sensitive environment variable values, email addresses and public IP addresses are removed. Request and app logs are kept 7 days and error samples 90 days (logs docs). That filter can’t recognize a name or a diagnosis your app prints, so redact at the source:

import pino from "pino";

export const log = pino({
  redact: {
    paths: [
      "req.headers.authorization", "req.headers.cookie",
      "user.email", "user.name", "user.phone", "body",
    ],
    censor: "[redacted]",
  },
});

log.info({ userId: user.id, action: "export_requested" }); // an ID, not the person

Where data and backups live (section 17)

Section 17 requires a privacy impact assessment and a written agreement before personal information is communicated outside Quebec, and its third paragraph applies the same rule when a provider outside Quebec collects, uses or keeps it for you. The test is Quebec, not Canada: a Toronto region counts.

The host is rarely the only copy. Error trackers, email services, analytics, AI APIs and off-site backups each keep something. Keep a data map in the repository, next to the code that adds integrations, so a pull request that adds a destination also updates the map:

// privacy/data-map.ts — reviewed with every new integration
export const DATA_MAP = [
  { system: "Postgres + backups", holds: "all account data", where: "Montréal (Canner)",   s17: "not needed" },
  { system: "Off-site backup copy", holds: "encrypted dumps", where: "bucket you choose", s17: "needed if outside Quebec" },
  { system: "Transactional email", holds: "email, name",     where: "check the vendor",   s17: "assess" },
  { system: "Error tracking",      holds: "stack traces",    where: "check the vendor",   s17: "assess; scrub PII first" },
] as const;

Questions

What does privacy by default mean under Law 25?

Section 9.1: if a technological product or service has privacy settings, they must provide the highest level of confidentiality by default, without the person doing anything. In practice, every privacy setting starts at its most private value. Browser cookie settings are excluded from that section.

Do I have to delete data from backups when a user is deleted?

The Act requires destruction or anonymization once the purposes are achieved (section 23); it does not mention backups. Our reading: set backup retention short enough that a deletion is complete within a known period, write that period in your retention policy, and don’t restore deleted users back into production.

What format does a Law 25 portability export need?

“A structured, commonly used technological format” (section 27). The Act names no format; JSON or CSV fits. It covers information collected from the person, not information you created or inferred from it.

Is hashing personal information anonymization?

Usually not. Section 23 requires that the information irreversibly no longer allow the person to be identified, directly or indirectly. A hash of an email can be recomputed by anyone who has the email, so it is at best de-identified, which section 12 treats as still personal information.

If I host on Canner, do I still need a privacy impact assessment?

For the system you are building, yes: section 3.3 applies on any host. For the hosting itself, no section 17 assessment is needed, because the data stays in Quebec. Integrations that send data elsewhere still need one.

Build it in Quebec from the first commit.

Apps, Postgres, files, logs and backups on Canner stay in Montréal. Start free.