Secure links, calendar files and privacy engineering
How a link can safely stand in for an account - random tokens, purpose binding, expiry, hashing and revocation - how magic links work and where they fail, how an iCalendar invitation is written so an update replaces it and a cancellation removes it, and the privacy-engineering techniques behind it all: data minimization, retention and scheduled erasure, anonymization versus deletion, personal-data markers, an audit trail without values, and fairness filters for AI text about people.
In short
Some people need to reach one record without ever creating an account: a candidate checking an application, an interviewer from another company leaving feedback, a client approving a design. {{brand}} gives them a secure link - an address that carries a long random secret, works for one purpose, expires and can be revoked. Interviews reach their calendars as iCalendar files that a later email can move or remove. Underneath sits privacy engineering: deciding what is kept about people, for how long, who may see it and what the audit trail may remember.
After this lesson you can Design a no-password link that survives logs, mail scanners and forwarded emails; explain magic links and their traps; write an .ics invitation that updates and cancels correctly; apply minimization, retention, anonymization and audit without values.
Words you need
- Capability URL A link that is the permission: whoever holds it may do one thing, with no sign-in. Also called a secret link or a bearer link.
- Token and entropy The random secret inside the link. Its entropy - the number of random bits - decides how hard it is to guess; 128 bits is a common floor, and more is cheap.
- Purpose and audience binding A token opens one kind of thing for one record - see my data, answer this offer - and only while the record still has the address it was sent to.
- Expiry and revocation Expiry ends a link at a set time. Revocation ends it early, on purpose: one link, or every link of a record.
- Security stamp A per-account value that changes on a password reset or a sign-out everywhere, so tokens that carry it die at once.
- URL fragment The part of an address after #. The URL standard makes it client-side only: the browser uses it to scroll or to feed scripts, and strips it from requests, so it never reaches a server, a Referer header or an access log.
- Magic link A link sent by email whose use proves you control the mailbox - to sign in, confirm an address or reset a password.
- iCalendar (.ics) The plain-text format of RFC 5545 that every calendar app reads: a calendar holding events, each with times, people and a status.
- UID and SEQUENCE UID names one event for its whole life. SEQUENCE counts its changes, so a calendar knows which copy is newer.
- Data minimization Collect, keep and show only what the purpose needs. A GDPR principle you design into the schema, not a policy you write afterwards.
- Anonymized or pseudonymized Anonymized data can no longer be tied to a person. Pseudonymized data still can, with a key or a lookup table - so it is still personal data.
- Fairness filter A check that AI text about a person states what was said and never judges them: no verdicts, traits or protected topics.
Secure links: how a link becomes a key
An account answers the question who are you? with a password, a passkey or a company sign-in. A capability URL asks a different question: what do you hold? If the secret in the link is real, live and meant for this page, the server lets you in. That is exactly right for a person who will open one record a few times and should never need to sign up - and exactly dangerous if the link leaks.
- 1. Generate Draw the token from a cryptographic random generator - never a GUID, a counter or Math.random - and encode it URL-safe (base64url).
- 2. Store a fingerprint Save only a one-way hash of the token, beside the purpose, the record it opens, a fingerprint of the address it goes to and an expiry.
- 3. Send it once The raw token goes into one email and nowhere else. Better still, mint the link at the moment the email is sent, so a queued email waiting for a retry holds no link.
- 4. Put it in the fragment The link looks like app.example.com/my-data#token=..., so the secret stays in the browser when the page loads.
- 1. Read and hide The page reads the token from the fragment and removes it from the address bar at once, so it does not stay in history or on a shared screen.
- 2. Send it in a header Every call to the API carries the token in a request header, never in the address, where servers and proxies would log it.
- 3. Check the shape first A token of the wrong length or alphabet is refused before the database is asked anything.
- 4. Hash and look up Hash what arrived and find the row by that hash; compare in constant time as a last check. A fast hash is enough because the token is long and random; a slow password hash is for guessable human passwords.
- 5. Check every binding Right purpose, not expired, not revoked, and the record still has the address the link was sent to and has not been erased.
- 6. Answer the same way Unknown, expired, revoked or wrong purpose all get the same 404 with the same body - a probe learns nothing.
- 7. Note the use Record when the link was last used, mark the answer no-store so no cache keeps it, and never change the record on a GET: stamping the link's last-used time is the only write.
- Issued A row is saved with its purpose, record, audience fingerprint and expiry; the raw token leaves once, in an email.
- Used Each successful open stamps the last-used time, so the owner can see which links are alive.
- Ended by expiry After the expiry time the link answers the same 404 as one that never existed.
- Revoked on purpose One link, or every link of a record, is switched off at once.
- Dead because the record changed The address it was sent to was corrected, or the record was erased: the binding no longer holds.
Every failure must look the same If an expired link answers link expired and an unknown one not found, an attacker learns which guesses were once real. Give every failure the same status and body, and offer one way forward for everyone: ask for a new link by email.
Two kinds of link token: stored or self-contained
A link token can be a random key the server looks up, or a self-contained token that carries its own data and a signature - a JWT, an HMAC over the fields, or ASP.NET Core's Data Protection. Both are used in real systems; they fail in different ways.
Products often use both: stored tokens for links to records, signed tokens for one-shot mailbox proofs. A reset link is usually single-use because the reset changes a per-account value the token carries - the security stamp.
Magic links: proving a mailbox
A magic link is a capability URL whose use proves something about the person: that they can read mail at this address. Many products use it to sign people in without a password. The token rules above all apply, and three traps are added: mail scanners, account enumeration and inbox flooding.
- 1. Ask with an address The person types their address. The answer is the same sentence for every address - if this address is known, a link is on its way - and takes the same time: the work is queued and done afterwards.
- 2. Cool down One email per address within a short cooldown, however often someone asks, so nobody can flood another person's inbox or spend the sending quota.
- 3. Make it single-use and short Minutes, not days, for a sign-in or a reset; bind it to a value that changes once it is used, such as the security stamp.
- 4. Land on a button The link opens a page that asks - Sign in, Reset, Confirm. Only the button's POST acts, so a scanner that opens the link changes nothing.
- 5. Act and tell Do the one thing the link was for, end the link, and tell the person by email when the action matters, such as a new password.
Typical uses: confirming an address, resetting a password, letting an outside reviewer find their link again.
The options in the market
- Make them create an account Sign-up, password or passkey, then the record.; A strong identity, with two-step sign-in; One place for all their records; Many candidates and clients give up at the sign-up; You hold more personal data than the task needs
- Stored random link A hashed, purpose-bound, expiring token per record and address.; Nothing to sign up for; Each link can be listed, revoked and seen in use; Stops by itself when the record or its address changes; Whoever holds the email can open it until it ends; One database read per use
- Self-contained signed token A JWT or an HMAC-signed address.; No storage, no lookup; Easy to check anywhere; One link cannot be revoked without a deny list; Long addresses; a key change ends every link
- Link plus a code or password The link, and a second secret sent or agreed separately.; A forwarded email alone is not enough; More friction for every visit; The second secret needs its own limits
- Free text, filtered afterwards The model writes; a block list removes bad sentences.; Fluent, natural summaries; No word list is complete; idioms slip through; Every language needs its own measured lists
- Closed topics plus word-for-word quotes The model picks from a fixed list and quotes the person; code checks both.; Nothing a model invented is shown as a judgement; Every output can be verified mechanically; Less fluent than prose; Quotes still need filtering per language
- AI scores or ranks people A fit score, a ranking, a recommendation.; Fast triage of many applicants; High-risk under the EU AI Act; Bias is hard to measure and impossible to explain to a rejected person
- No AI on people's text Reviewers read everything themselves.; No risk from the model at all; No help with long answers
What {{brand}} chose, and why
Hiring and sharing bring in people who are not members of a workspace: candidates, interviewers from outside, clients, reviewers. Asking each of them for an account would collect personal data the task does not need and lose many of them at the door. So {{brand}} uses stored random links - one shared design for every module - and follows the data minimization principle further: a person without an account is known only by what the hiring team or the share needs.
- One link, one purpose, one record: a link to see your data cannot answer an offer, and a link to one application opens nothing else.
- Bound to the recipient's address: if that address is no longer the record's, the link stops by itself.
- Lifetimes sized to the task: short for a quick answer, longer for looking at your own data; a new link is always one request away.
- Only a hash is kept; the link travels in the fragment and comes back in a header.
- Optional extra lock: a shared link can also ask for a password, so a forwarded email alone is not enough.
- AI about people: closed topics and the person's own words, never a score - see Fairness filters below.
The trade-off accepted A link is a bearer credential: anyone who receives the email can use it until it ends. {{brand}} keeps that window small with purpose, audience and lifetime, and makes a fresh link one request away. Links read by other programs, such as calendar feeds, cannot use headers; for those the right controls are a hashed token, revocation and a short list of live links - a trade-off worth knowing when you design your own.
Another choice would be right elsewhere. People who come back every day deserve accounts with two-step sign-in. A link that moves money or signs a contract needs a second factor. A huge public service checking millions of links at the edge may prefer self-contained tokens and accept weaker revocation.
iCalendar invitations: one event, many updates
An .ics file is plain text in the format of RFC 5545. Sent by email - the iMIP binding of RFC 6047 - it becomes an invitation the mail client offers to add. What makes it powerful is not the first email but the later ones: the same UID with a higher SEQUENCE replaces the copy in the calendar, and METHOD:CANCEL removes it.
- Scheduled METHOD:REQUEST, SEQUENCE:0. Each person's calendar adds the event under its UID.
- Moved The same UID, SEQUENCE:1, the new time. Calendars replace their copy instead of adding a second one.
- An interviewer is taken off That person alone gets METHOD:CANCEL with the UID; everyone else keeps theirs.
- Called off METHOD:CANCEL, STATUS:CANCELLED, a higher SEQUENCE. The event leaves every calendar.
- Line endings are CRLF, and a line longer than 75 octets - bytes, not characters - is folded: a line break, then one space. Never fold in the middle of a UTF-8 character such as ə or ş.
- Escape TEXT values: backslash, semicolon and comma get a backslash, and a line break becomes the two characters \n.
- Quote parameter values such as CN="..." and strip quotes and control characters from names, or a name can inject a property of its own.
- Write only web addresses into URL: a javascript: link inside an invitation is an attack, not a link.
- Use UTC unless you also ship the VTIMEZONE; a bare local time is read differently by every calendar.
- Label the email part: iMIP asks for Content-Type text/calendar; method=REQUEST (or CANCEL). Without the method, some mail clients show only a file to open instead of a meeting request.
- Emailed .ics An invitation file with REQUEST and CANCEL.; Works with every calendar and mail client; Updates and cancellations are plain emails; Nothing to connect or authorize; Mail clients differ in how they show it; No proof the person accepted unless replies are possible
- Calendar API Write to Google or Microsoft calendars directly.; Reliable updates, no email needed; Can read acceptance; An OAuth connection and scopes per provider; Each provider has its own quirks and limits
- Subscribed feed The calendar app polls an address.; No email at all; Always the current state; Slow polling, so changes appear late; The address is a bearer secret
- Link to a booking page The email links to a page; the person adds it themselves.; Simple to build; The event is not in the calendar until someone acts; Nothing updates by itself
Send one invitation per recipient, naming only them, so no address reaches another person. Write times in UTC, so an interviewer in Baku and a candidate in Berlin each see their local time. If the organizer address cannot receive mail, set RSVP=FALSE so the calendar does not offer a reply nobody will read.
Privacy engineering: principles turned into code
GDPR Article 5 lists the principles for personal data, and Article 25 asks for data protection by design and by default. They are easy to agree with and hard to build. Privacy engineering is the craft of turning each one into a schema decision, a default or a scheduled job, so a busy developer cannot forget it.
- Do we need it? If the feature works without it, do not collect it. A fingerprint or a yes/no often does the job of the value.
- For how long? Decide the retention now and write down what starts the clock - finished, closed, consent ended.
- Who may see it? The hiring team, the person themselves, an AI feature? Each audience is a separate decision.
- Does the audit need its value? Almost never. Mark it personal so the log records the change without the content.
- How is it erased? Delete it, clear it on a kept row, or replace the person with a placeholder - and test that path.
Retention and scheduled erasure
A retention policy is a rule for how long data is kept, and a policy that nobody enforces is a sentence in a document. Enforcing it takes a scheduled job that finds what ran out, erases it the same way a person's request would, and is safe to run again and again. A typical design lets each workspace choose from a few fixed periods, counted from when the record finished or its parent closed, whichever came first.
- 1. A small set of choices Offer a few periods, not any number: each choice is one cut-off query, and the default covers everyone who chose nothing.
- 2. Find what is due Rows whose clock started before now minus the period, including soft-deleted ones - their data is exactly what retention is for.
- 3. One runner at a time The job runs on a schedule, and a lease - a short-lived lock - makes sure only one runner works at once.
- 4. Small batches, oldest first Work in small batches, oldest first, so one run never holds long locks.
- 5. Erase like a request Delete the files first, clear the personal fields, erase what describes the person, keep an anonymous row for the numbers.
- 6. Tell the others, and retry Other modules that copied a name hear of the erasure; if telling them fails, the next run tries again.
- 7. Idempotent A second run finds nothing new and changes nothing.
Consent is retention too Consent-based data (a talent pool, a newsletter) needs a retention rule too: when consent ends, the data goes.
Delete, anonymize or pseudonymize?
When personal data must go, there are four tools, and they are not interchangeable. The question is always: what does the rest of the system still need, and can what is left be tied back to the person?
Anonymous is harder than it looks Removing the name is not enough. Latanya Sweeney showed that ZIP code, birth date and sex alone single out most Americans. Free-text fields, file names, an id that maps back and exact timestamps combined all re-identify. Clear every free-text field, give the row a new random id with no table that maps it back, and keep only what the numbers need.
A typical design keeps the row a pipeline needs, with its stage and dates, clears every field that describes the person, replaces the link to any account with a new random id (or clears it when there was none), and removes files and copies elsewhere. The row-by-row techniques are in the Data lesson.
Personal-data markers and an audit trail without values
An audit trail that copies every old and new value is a second database of personal data - and the erasure job usually forgets it. The fix is to mark personal fields in the model once. The logging code reads the marker: it records that the field changed and who changed it, never what it held.
- An erasure leaves no values: its entry says Erased and nothing else, so there is nothing left to restore or read.
- Do not name the person: when someone applies, withdraws or erases their own data, it is saved with no author, so a kept row cannot point back to them.
- Skip the noise: high-frequency bookkeeping rows would bury real changes, so decide deliberately what is logged.
Fairness filters for AI text about people
The EU AI Act lists AI used to recruit or select people as high-risk. A model's bias is hard to measure and impossible to explain to the person it rejected. {{brand}}'s rule is short: AI may summarize and quote, labelled and editable, and it never scores, ranks, recommends or rejects a person. Fairness filters are the code that holds the model to that rule.
- 1. A language we can read If the answer is not written in a language whose filters were measured, there is no model call and no summary.
- 2. Only the material The model gets the question and the answer - never the name or the address - marked as material to read, not instructions.
- 3. A closed answer It returns topics from a fixed list and up to a few passages quoted from the answer.
- 4. Verify mechanically Every topic must be on the list and every quote must appear word for word in the answer - or the whole summary is dropped.
- 5. Filter the quotes A quote that carries a verdict, a trait, a link or an address is left out: a candidate may have written "hire me, I am an excellent tester" hoping to see it under an AI label.
- 6. Show our words and theirs Reviewers see {{brand}}'s own wording of the topics and the candidate's own sentences, beside the whole answer.
- Write the lists Verdict words, traits, comparisons with people and protected topics - age, family, religion, health, origin - in that language's own forms and word order.
- Measure on a tuned set Real-looking answers and questions the lists were built on: how many verdicts are caught, how much harmless text is dropped.
- Measure on an unseen set A second set nobody tuned on. This is the honest number; idioms show up here.
- A native reader checks A person who speaks the language reads the lists and the sets.
- On only above the bar A language that misses the bar stays off: its teams get their own wording and no summary. Tests pin the measured numbers, so a list change that moves them fails.
Fail closed A language nobody measured shows nothing, not a weaker filter. A check that cannot finish counts as a fail. The same idea runs through the AI-safety lesson: /help/technology/ai-safety.
Try it yourself: a secure link and a calendar invitation
The exercises need only Node.js, any calendar app and a database such as PostgreSQL or SQLite. Run the server on localhost and watch what reaches its log.
- 0. Set up Make a folder, run npm init -y and npm i express, add "type": "module" to package.json, and save the two files below as server.mjs and public/my-data.html.
- 1. Issue Run node server.mjs. It makes a 192-bit token, stores only its hash with a purpose and an expiry, and prints the link with the token in the fragment.
- 2. Open it Open the link in a browser. The server's log shows the page request without the token.
- 3. Hide it The page reads location.hash, calls history.replaceState to remove it from the address bar, and sends the token in a header.
- 4. Break it Try an unknown token, an expired one (set days to 0) and one with the wrong purpose: all three must get the same 404.
- 5. Put it in the query Open http://localhost:3000/api/q?token=abc123 and watch the whole token appear in the server's log line - the reason not to.
- 6. Revoke Add a small /revoke route that sets revoked to true on the link, call it, and open the link again: it answers the same 404.
- 1. Write it Save the example invitation above (SEQUENCE:0) as interview.ics with CRLF line endings - run unix2dos interview.ics, or pick CRLF in your editor's status bar - and open it with Apple Calendar or Outlook; Google Calendar's import ignores METHOD. Even better, email the file to yourself.
- 2. Move it Change DTSTART and DTEND, set SEQUENCE:1 and open it again: the event moves, no second copy appears.
- 3. Forget the SEQUENCE Change the time but keep SEQUENCE:1: many calendars ignore it or ask what to do.
- 4. Cancel it Set METHOD:CANCEL, STATUS:CANCELLED and SEQUENCE:2 with the same UID: the event disappears.
- 5. Change the UID Send an update with a new UID and watch a duplicate appear - the most common invitation bug.
- 1. Make a table In PostgreSQL or SQLite create people(zip, birth_year, sex, city, note).
- 2. Fill it Insert about 30 rows with a few zip codes, birth years and both sexes (the statement below does it in PostgreSQL).
- 3. Look for the lonely ones Group by zip, birth_year and sex and keep the groups with exactly one row.
- 4. Read the result Every row returned is unique in that combination, so anyone who knows those three facts can find it - even with the name removed.
- 5. Generalize and repeat Cut the zip to a prefix and the birth year to a five-year band, then group again until no group has fewer than five people.
- 6. Clear the rest Set the free-text note to null and drop any id that maps back to a person; a kept id must be a new random value.
Common mistakes
- Tokens in query strings: they land in every access log, proxy and analytics tool. Use the fragment on the way in and a header on every call.
- A GET that acts: mail scanners open every link, so a link that unsubscribes, confirms or signs in on GET acts for people who never clicked. Show a page; act on POST.
- Storing raw tokens or telling failures apart: a database copy then opens every link, and expired versus unknown tells an attacker which guesses were real. Keep hashes; answer one 404.
- A new UID for every update, or a SEQUENCE that does not grow: calendars show duplicates or ignore the change. One UID for life, SEQUENCE plus one per change, METHOD:CANCEL to remove.
- An audit log that copies values, or anonymization that only drops the name: erasure then leaves copies behind. Mark personal fields, clear free text, re-key with no way back.
- Letting AI judge people: a fit score or a free-text verdict is a hiring decision in disguise. Use closed outputs, verify them in code, and switch a language on only after measuring it.
Interview questions
- What is a capability URL, and when is it the right choice? A link whose secret is the permission, so whoever holds it may do one thing without signing in. It suits people who visit one record a few times, such as a candidate or an outside reviewer; people who return daily deserve accounts.
- How do you keep a link token out of logs? Put it in the URL fragment, which browsers never send to servers or in the Referer, remove it from the address bar after reading it, and send it in a request header. Add Referrer-Policy: no-referrer and Cache-Control: no-store on what it opens.
- Why hash a link token, and why is a fast hash enough when passwords need a slow one? So a stolen database copy opens nothing. Passwords are low-entropy and need a slow, salted hash such as bcrypt or Argon2; a token with 128 or more random bits cannot be guessed, so a fast cryptographic hash is enough and the row can still be found by an index.
- Stored random token or JWT for an emailed link? A stored token can be revoked one by one, shows when it was used and stops when its record changes. A JWT needs no storage, but revoking one needs a deny list and a key change ends them all. For personal links to records, stored tokens win.
- What goes wrong with magic links? Mail scanners open links, so a GET that acts would sign people in without them; land on a button and act on POST. Answering differently for known addresses lets attackers enumerate accounts, so answer identically and queue the work. A cooldown stops inbox flooding.
- Why must a GET never change state? Scanners, prefetchers, crawlers and link previews all issue GETs without a person clicking. Actions belong behind a POST triggered by a person.
- How does a calendar know an invitation is an update and not a new event? The same UID with a higher SEQUENCE and METHOD:REQUEST replaces its copy; METHOD:CANCEL with the same UID removes it. A new UID creates a duplicate.
- Anonymization versus pseudonymization? Anonymized data cannot reasonably be tied back to a person and falls outside GDPR; pseudonymized data can be, with a key or lookup, so it is still personal data. Removing a name alone is rarely anonymization.
- How do you keep an audit trail without keeping personal data? Mark personal fields in the model and have the logger record that they changed without their values. Erasure entries keep no values at all, and actions a person takes on their own record are saved with no author.
- How do you test that data is really anonymous? Group by the quasi-identifiers (zip, birth year, sex) and look for groups of size one, which is the idea behind k-anonymity. Generalize until no group is small, then clear free text and replace any id with a random one.
Related
- /help/technology/files-notifications
- /help/technology/data
- /help/technology/ai-safety