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.
| Section | What it says, in short | What 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 necessary | A 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 needed | A consent table keyed by purpose and wording version |
| 23, 3.2 † | Destroy or anonymize when the purpose is achieved; a policy frames keeping and destruction | A 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 incidents | An incidents table and the habit of filling it |
| 10, 5 † | Security measures reasonable for the sensitivity; collect only what is needed | Logs that hold IDs, not names, emails or bodies |
| 17 † | Assess before personal information leaves Quebec, including to a provider that keeps it | A 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 storedConsent records you can show (sections 12 and 14)
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 recordExports 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 personWhere 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.
Related
- GuideQuebec’s digital sovereignty statement
- BlogCanada's AWS Alternative — By Accident
- BlogWhy We Moved Canner Off a Fleet of Virtualized Servers and Onto Our Own Hardware
- BlogHow to Build a 100% Canadian Stack
- BlogA Weekend on the Wrong End of the Stack
- BlogShared Services Canada Wants a Sovereign Cloud. What Would That Actually Take?
Build it in Quebec from the first commit.
Apps, Postgres, files, logs and backups on Canner stay in Montréal. Start free.