Family calendar · inside Home Assistant · 1.3

A family calendar
that stays at home.

Kev asked for a Google-like calendar in Home Assistant, for three people, each with their own account and with alerts, and above all secure. So it was built from scratch as a Home Assistant integration. It does what you'd expect from Google Calendar, and then some: where everyone has been, health from Withings, and calendars followed from other apps. Every event, password and position lives on the Pi in the house, behind a long list of protections. This page lists all of them.

custom HA integration own sign-in + two-step live sync installable app no third-party requests

6views, day to year
~1 sfor a change to reach every screen
56automated tests, attacks included
0third-party requests from the app
01 · What it does

Everything you'd expect, and a bit more

It lives in the Home Assistant sidebar and on its own tab on the wall panel. It also installs on a phone like an app. Kev signs in with his Home Assistant account; Linda and the rest of the family have calendar-only logins, with no access to the rest of the house.

Six views
Day, 3 days, week, month, year and schedule. Drag to move or resize, and swipe between days on a phone. On the wall panel the day fits the screen, and it goes back to today after a few idle minutes.
Type it like you'd say it
"Lunch with Mum at the Swan 1:30pm every Saturday #family" fills in the time, the place, the repeat and the label. Repeating events can be edited for this one, this and following, or all.
Whose is it?
Each event wears its owner's initials in a little badge, with a border in their colour. Labels such as Work or Health give it a colour of their own, and can be hidden with one tap.
Where everyone has been
A thin strip down each day shows each person's stays and journeys. They come from the van's tracker and the phones. Name a place once and every visit there is labelled. A visit can become an event in one tap.
Health on the day
Weight, sleep score and steps from Withings appear under each day. Last night's sleep is drawn as a band across the hours. Each person picks what shows, and separately what the family may see.
Follow other calendars
Paste a webcal or https address, for example Stremio's new-episode calendar, a school's terms or a Google calendar. It stays up to date by itself and is read-only.
Alerts that reach you
Reminders pop up in the app, arrive as notifications on installed phones, and come through the Home Assistant app. Snooze works straight from the notification.
Live sync
A change on one screen appears on every other one in about a second. Undo works for ten minutes, and deleted events wait in a bin for thirty days.
Plays well with others
Import and export standard .ics files, bank holidays from gov.uk, and a documented API ready for an Android app later.
02 · How a day reads

Plans and what actually happened, side by side

This is a drawn example, not real data. The events show what was planned. The strips on the left show where each person actually was: solid at home, lighter somewhere else, dashed travelling, hatched when a phone said nothing. Sleep sits behind the morning, and health sits under the date.

03 · Security, end to end

What a request has to get past

The calendar can be reached from outside the house, so every request passes the same series of checks before it touches any data. Who is asking is decided once, on the server. The app is never trusted to hide anything.

1 · transport
HTTPS only
Signing in is refused over plain http. The real client address is only believed when the request came through the house's own tunnel.
→
2 · who are you
Session or HA login
A calendar session cookie, a future app's token, or Kev's own Home Assistant login. Nothing else gets in.
→
3 · is it really you
Forgery checks
Every change must carry the app's own header and come from the calendar's own site.
→
4 · not too fast
Rate limits
Per person, per address and per password check.
→
5 · may you see it
Permissions
Sharing, private events, location and health rules, applied before anything is sent.
04 · Every measure

The full list

Grouped by what each measure protects against.

Getting in

  • Passwords are stored only as bcrypt hashes (cost 12), never readable. At least 10 characters. Common passwords and ones containing the person's name are refused.
  • Each person sets their own password from an invite link. The administrator never sees or sets anyone's password.
  • Two-step sign-in for calendar logins: a 6-digit authenticator code after the password (RFC 6238). Ten single-use recovery codes are stored only as hashes. A code can never be used twice, and the code step expires after 5 minutes or 5 wrong tries.
  • Account lockout: 5 wrong attempts lock the account for a minute, doubling each time up to an hour. Wrong two-step codes count too.
  • Address throttle: 30 failed sign-ins from one internet address in an hour blocks that address for an hour, whatever usernames it tries.
  • No username fishing: every wrong sign-in gets the same reply. Unknown usernames take the same time to answer and lock out exactly like real ones, so nothing reveals who has an account.
  • Invite and reset links work once and expire after 3 days. The secret is in the part of the link after the #, so it never reaches a server log, and only its fingerprint is stored. A new link also clears two-step, which is how someone who lost their phone gets back in.
  • Kev uses his Home Assistant login, so the calendar holds no separate password for him.

Staying signed in

  • The sign-in cookie (__Host-, HttpOnly, Secure, SameSite=Strict) can't be read by page scripts, only travels over https and isn't sent by other websites.
  • Session tokens are stored only as fingerprints (SHA-256), so a copy of the database contains no usable sign-ins.
  • Sessions expire after 90 days unused, or 365 for a future phone app.
  • Every signed-in device is listed and can be signed out on its own.
  • Changing a password signs out every other device. Switching a person off cuts them off immediately, including any live connection.
  • Signing out also removes that device's alert subscription.

Attacks through the browser

  • Forged changes: every change must carry a header only the calendar app sends, and is checked against a list of allowed origins. Another website can't act on your behalf.
  • The page's own rules (Content-Security-Policy) mean it may only run its own code and styles, connect only to its own server, and can't be framed by other sites.
  • Protective headers on everything: nosniff, no-referrer, same-origin opener and resource policies, and camera, microphone and location access switched off.
  • No third-party requests. Fonts, icons and code are all served from the Pi, so no outside service sees who opens the calendar.
  • The file server only hands out the app's own files (tested against ../ path tricks).
  • Text is always inserted as text, never as HTML, so an event title can't run code.

Flooding and overload

  • Per person: 300 requests and 60 changes a minute.
  • Password checks run at most two at a time, so a flood of sign-ins can't swamp the Pi.
  • At most 20 live connections per person.
  • Caps on everything: request size, text lengths, date ranges, import size, how many people, calendars, labels and places, and how many calendars one person can follow.

Who sees what

  • Enforced on the server, not hidden in the app: the app only ever receives what its user may see.
  • Private events reach others only as "Busy": no title, place, guests or labels. Search never matches inside them, export writes them as Busy, and free/busy shows only times.
  • Sharing per calendar and per person: none, free/busy, all details, or can edit.
  • Followed calendars are read-only for everyone, owner included.
  • Labels and named places can only be changed by whoever made them, or the administrator.

Location privacy

  • Coordinates never leave the Pi. The app only receives times, place names and distances.
  • Only the person can hide their location, or choose which of their devices show. Nobody else can change it, not even the administrator.
  • The administrator only links devices to people. He can't see or change their sharing.
  • Places are named privately with no outside map lookups. Home comes from Home Assistant itself.
  • Honest gaps: when a phone goes quiet the strip says "no signal" instead of guessing where someone was.
  • Phone-app positions are logged on the Pi and kept for 400 days.

Health privacy

  • Private by default. Nobody else sees any health data until its owner switches it on.
  • Sharing is one metric at a time, for example steps but not weight. "Show me" and "share with the family" are separate switches.
  • The data stays where it already was: the private Withings file on the Pi, read behind the login.

Followed calendars, safely

  • The feed address is a secret. Stremio's carries a private key to the account. It stays on the server: others see only the website name, and the owner a shortened form.
  • The house network can't be reached through it. Anyone with a login can add an address, so the server only ever fetches public internet addresses. It refuses home and private addresses, including the number and hex tricks for writing them, names that secretly point at a home address, and redirects to any of these. Every redirect is re-checked.
  • Strict limits: standard web ports only, 5 MB, 25 seconds, five redirects.
  • In-place sync: events keep the same identity from one refresh to the next, so reminders don't repeat and links don't break. Reminders are off unless the owner turns them on.

Data on the Pi

  • One database, private to Home Assistant: its folder and file can only be opened by HA itself.
  • Backed up automatically before every update, using the database's own safe online copy.
  • Upgrades never lose data: each new version upgrades the database in place and keeps everything in it, and the upgrade itself is tested.
  • Kept only as long as useful: bin 30 days, phone positions 400 days, security log 1 year, Undo 10 minutes (in memory only).

Alerts and the installed app

  • Alerts to installed apps are encrypted end to end (Web Push). Google or Apple only carry an envelope they can't open.
  • The app's offline copy holds the app only, never any calendar data.
  • Notifications open the calendar's own page, and Snooze works straight from them.
05 · Proof

Tested like an attacker would try it

56 automated tests are run before every update: 45 against the calendar's own logic and 11 against a real (test) Home Assistant over HTTP. They don't just check that features work. They try to break in.

What the tests attack

  • Requests without the app's header, or with no sign-in.
  • Guessing passwords until locked out, and checking that the lock looks the same for real and made-up usernames.
  • Reusing a two-step code, and using a recovery code twice.
  • Peeking at private events in the calendar views and through search.
  • Seeing someone's location after they hid it, as another person and as the administrator.
  • Feed addresses aimed at the house: plain home addresses, number and hex forms, IPv6 tricks, and a name whose lookup points home.
  • Path tricks against the file server, and upgrading old databases.

Caught before going live

The attack tests did their job. They caught two ways to slip a home address past the feed checker in the first draft, before it went live. A review also found that the sign-in lockout hinted which usernames were real, and that was fixed too. Each of these now has a test of its own, so it can't come back.

Every sign-in, failed attempt, invite, two-step change, device link and new subscription goes into a security log the administrator can read. It records the address each attempt came from.

06 · Build your own

One prompt, for any Home Assistant

Want the same calendar at your house? Copy this prompt into Claude (Claude Code is ideal, because it can work on your files and run the tests). Every home is set up differently, so the prompt doesn't assume anything. It makes Claude find out your setup first: how Home Assistant is installed, how you reach it from outside, who lives there, and which trackers and health apps you use. Then it builds to the same security and privacy rules as this page.

01

Paste the whole prompt

Send it as one message. It has three jobs: the questions to ask you, the rules that are not optional, and the traps we fell into so yours doesn't.

02

Answer its questions

It checks what it can itself (versions, installed libraries, trackers) and asks you the rest, such as who gets a login and what the family may see. Then it shows you a one-page plan to approve.

03

Build in phases, with the tests

Core first, then the web side, then the app, then location, health and followed calendars if you want them. Each phase arrives with tests that try to break in. Nothing is restarted without a configuration check and a backup.

2,573 words · copies as plain text
# Build a security- and privacy-first family calendar for Home Assistant

You are going to build a Google-Calendar-style family calendar as a custom Home Assistant integration: each person has their own account, events sync live, reminders reach their phones, and it can also show where people have been, their health data, and calendars followed from other apps. Security and privacy are the first requirement, not an add-on: everything below the "Security" heading is mandatory.

You don't know this household's setup yet. Find it out first, adapt the design to it, and confirm with the owner before you build.

## How to work

- Never guess. Look at the real system (files, versions, entities, what's installed) and say "I haven't checked" when you haven't. Ask when a decision belongs to the owner.
- Work in phases, and show the owner a working result after each: core and tests, then the HTTP layer, then the app, then optional modules.
- Touch nothing outside this integration without asking. Back up any file before you change it. Run Home Assistant's configuration check before every restart, and ask before restarting unless the owner has said you may.
- Never print, log or commit secrets: tokens, passwords, feed addresses with private keys, API keys. Don't pass secrets through Home Assistant service calls; the recorder stores their data.
- Keep a short plan and status file in the project, and write down every decision the owner makes.

## Step 1: find out the setup (ask, and inspect where you can)

1. Home Assistant: the version, the install type (OS, Supervised, Container or Core), the Python version, and how you can work on it (SSH add-on, Samba, shell on a Docker host, or only through the owner). Where the config folder is.
2. Libraries already in Home Assistant's Python: check which of bcrypt, cryptography, icalendar, python-dateutil, pywebpush/py_vapid, segno (QR codes) and tzdata import, and at what versions. Prefer what's already there. If you need something missing, pin a version that doesn't conflict with that Home Assistant release.
3. Reaching it from outside: Nabu Casa, Cloudflare Tunnel, a reverse proxy (nginx, Caddy, Traefik), a VPN (WireGuard, Tailscale) or local network only. This decides whether HTTPS is available (needed for secure cookies, sign-in and web push) and which proxy, if any, may be trusted to report the real client address. Find out what address the proxy's traffic arrives from.
4. People: how many, who already has a Home Assistant user (they can sign in with it), who gets a calendar-only login with no access to the rest of Home Assistant, and who is the administrator.
5. Privacy defaults: can everyone see everyone's events by default (with a per-event "Private" that shows others only "Busy"), or is everything private until shared?
6. Alerts: the Home Assistant Companion app (which phones?), installed web app with Web Push (needs HTTPS), or both.
7. Optional modules. For each, does the owner want it, and what's the default visibility?
   - Location: which sources exist? Home Assistant Companion app trackers, Traccar, OwnTracks, GPSLogger, or none. Which devices belong to whom?
   - Health: Withings, Fitbit, Garmin, Google or Apple exports, or none. Whose account is it?
   - Followed calendars (webcal/ICS feeds such as TV, school or sports calendars).
   - A wall panel or kiosk dashboard.
   - Public holidays: which country or region?
8. Time zone, first day of the week, and any calendars to import.

Write a one-page summary of the setup and the plan, and get the owner's yes before building.

## Architecture

- A custom integration with a pure-Python core (no Home Assistant imports, so it can be tested anywhere) and a thin Home Assistant layer.
- Storage: one SQLite file in its own folder inside the config folder. The folder is 0700 and the file 0600. Never use the `www` folder: Home Assistant serves it without any login.
- Times: store local wall-clock time plus an IANA time zone, and expand repeats in local time, so a weekly 09:00 stays at 09:00 across daylight saving changes. Send the app local times plus UTC.
- API: JSON under `/api/<domain>/v1/`, registered as Home Assistant views that do their own authentication. A request is either (a) a Home Assistant user, already validated by Home Assistant's own auth (read the current Home Assistant source to see how the authenticated user is attached to a request), linked to a calendar account; or (b) a calendar session (an HttpOnly cookie for the web app, or a bearer header for a future native app). Nothing else is accepted.
- Live sync: Server-Sent Events with a heartbeat. The server sends "bye" both when someone signs out and when Home Assistant shuts down, so the app must check whether it's really signed out before showing the sign-in page.
- The app: plain JavaScript and CSS served by the integration, with no build step and no CDNs. Self-host the fonts and icons. Inside Home Assistant (sidebar panel or dashboard iframe), call the API through the parent page's `hass.fetchWithAuth`, never a stored token. Note that `hass.callApi` can't send custom headers. Outside Home Assistant it's an installable PWA.
- Features to aim for: day, 3-day, week, month, year and schedule views; drag to move and resize; natural-language quick add; repeating events with this / this-and-following / all edits; guests and responses; reminders with snooze; private events; per-calendar sharing; search; free/busy and "find a time"; labels with colours; owner initials and owner-coloured borders on events; undo; a bin; ICS import and export; public holidays; a phone layout; a kiosk mode that fits the day to the screen.

## Security (mandatory)

Signing in
- Hash passwords with bcrypt (cost 12; pre-hash with SHA-256 so long passwords aren't truncated). Require at least 10 characters, and refuse common passwords and ones containing the person's name or username.
- People set their own passwords from a one-time invite link that expires within days. Put the token after the `#` so it never reaches a server log, and store only its SHA-256. The administrator never sees or sets anyone's password. A new link for an existing person also clears their two-step setup, which is the lost-phone recovery route.
- Two-step sign-in for calendar-only accounts: TOTP (RFC 6238) with a QR code, plus 10 single-use recovery codes stored only as hashes. Store the last accepted time step so a code can never be used twice. The half-finished sign-in expires after 5 minutes or 5 wrong codes. People with a Home Assistant login use Home Assistant's own two-step.
- Account lockout after 5 misses (1 minute, doubling, capped at an hour); wrong two-step codes count. Throttle by client address too (for example 30 failures an hour blocks that address for an hour).
- No username enumeration: identical replies and timing for wrong passwords and unknown usernames (verify against a dummy hash), and the lockout must behave identically for made-up usernames (count those misses in memory).
- Sign-in only over HTTPS, unless the owner explicitly allows plain HTTP on a local-only setup.
- Trust X-Forwarded-For or CF-Connecting-IP only when the request comes from the proxy's own address. Don't rely on Home Assistant's IP ban for app logins: behind a tunnel every request can appear to come from one address.

Sessions
- Cookie: `__Host-` prefix, HttpOnly, Secure, SameSite=Strict. Store only the SHA-256 of session tokens. Use sliding expiry. List every signed-in device so people can sign them out one by one. A password change signs out every other device, and disabling a person cuts them off at once, including their live connection. Sign-out also removes that device's push subscription.

Browser attacks
- Every request that changes something must carry a custom header that only the app sends, plus an Origin allow-list check.
- The app page sends a strict Content-Security-Policy (`default-src 'none'`; scripts, styles, fonts and connections from 'self' only; `frame-ancestors 'self'`; `base-uri 'none'`), plus nosniff, no-referrer, same-origin opener and resource policies, and a Permissions-Policy that turns off camera, microphone and geolocation.
- Build the DOM with text nodes only, never innerHTML with data. Beware that `append(null)` and `replaceChildren(null)` print the word "null"; filter nulls out.
- Serve static files from an allow-list pattern only, and test it with `../` and encoded tricks.

Abuse limits
- Per account: around 300 requests and 60 changes a minute. At most two bcrypt checks running at once. Cap live connections per account. Cap request bodies, text lengths, date ranges, imports, and the number of people, calendars, labels, places and followed feeds.

Who sees what (enforced on the server, never by hiding things in the app)
- A private event reaches anyone without access as "Busy" only: no title, place, guests or labels. Search must not match inside it, export writes it as Busy, and free/busy returns times only.
- Sharing per calendar and per person: none, free/busy, details, or can edit.

Data
- Back up the database before every update, using SQLite's online backup API. Make every schema upgrade transactional and idempotent: check whether a column exists before adding it.
- Retention: a bin with a time limit, a security log kept for about a year, undo held in memory only, and a limit on how long location history is kept.
- Keep a security log of sign-ins, failures with the client address, invites, two-step changes, device assignments and new feed subscriptions. The administrator can read it.

Alerts
- Web Push must be end-to-end encrypted (VAPID; RFC 8291 payloads). The PWA's offline cache holds the app shell only, never calendar data.

## Privacy (mandatory)

- Location: coordinates never leave the server. The app receives only times, kinds (stay, journey, no signal), place names and distances. Only the person can hide their own location or choose which of their devices show; store this in their own preferences, which only they can change. The administrator assigns devices to people but can't see or change their sharing. Name places without outside map lookups (use Home Assistant zones plus places the family names). Show "no signal" honestly rather than guessing.
- Health: private by default, shared one metric at a time by its owner. "Show me" and "share with the family" are separate switches.
- Followed calendars: the feed address is a secret (it often contains a private key). Keep it on the server and show only the host, plus a shortened form to its owner. Followed events are read-only for everyone, including the owner.
- No third-party requests from the app: no analytics, no CDNs, no web fonts from elsewhere.

## Optional modules: how to build them well

Location
- Work out stays, journeys and gaps yourself from raw positions. Many trackers' own "stops/trips" reports fail on phones that report only a few times a day (expect a single "trip" lasting days).
- A workable method: cluster consecutive positions within about 150 m. A cluster lasting 8 minutes or more is a stay; two reports at the same place hours apart also count as a stay. Between stays you have a journey, unless positions there are more than about 2 hours apart, which makes it a gap. Keep the newest stay open up to "now" for a while. Merge stays at the same spot split by a few minutes of GPS jitter. Drop hopeless fixes (huge accuracy values, 0,0).
- Compute per device and per local day, cache finished days, and refresh today every few minutes. Clip at local midnight, looking at the previous day so overnight stays are complete.
- Home Assistant's recorder keeps only about 10 days by default, so log Companion app positions yourself from state changes, and backfill from the recorder once.
- Show it as a thin strip per device down each day (solid = home, lighter = elsewhere, dashed = travelling, hatched = no signal), with "name this place" and "add as an event" on tap.

Health
- Show per-day chips (weight, sleep score and duration, steps and so on). Assign sleep to the morning you woke up, and draw your own sleep as a band across the night (keep the end time from the source). Others see only shared metrics.

Followed calendars (feeds)
- Accept webcal://, https:// and http://, turning webcal into https. Fetch on the server, and protect the house network (server-side request forgery), because anyone with a login can add an address:
  - Only ports 80 and 443. No usernames or passwords inside addresses.
  - Check the host exactly as your HTTP library will use it. Some URL libraries rewrite numeric hosts such as `3232235631` or `0x7f000001` into plain IP addresses, which then skip DNS. Refuse hosts made only of digits or hex unless they're a normal public IP.
  - Literal IP hosts must be globally routable, and that includes IPv4-mapped IPv6.
  - Resolve hostnames through a resolver that returns only public addresses. This also stops names that point at the home network.
  - Follow redirects by hand (at most 5), and re-check every hop.
  - Set a size cap (for example 5 MB) and a timeout.
  - If your errors subclass ValueError, don't wrap your own raise in `except ValueError`, or the refusal gets swallowed.
  - Read the body to the end: in aiohttp, `content.read(n)` can return just one chunk.
- Sync in place by UID, so event IDs stay stable and reminders don't repeat on every refresh. Remove events that left the feed. Keep a changed single occurrence (RECURRENCE-ID) as its own event and drop that date from its series, matching by local day: a series written in UTC repeats in local wall time and can sit an hour away from the feed's RECURRENCE-ID across a clock change. Honour the feed's refresh interval (clamped to 1-24 hours). Reminders are off unless the owner turns them on.

## Testing (mandatory)

- Unit tests for the core, with a fake clock and fast bcrypt.
- HTTP tests against a real test Home Assistant (pytest-homeassistant-custom-component). These run on Linux or WSL, not Windows. The socket-blocking plugin needs its enable fixture.
- Attack tests, at least:
  - a missing app header, and a foreign Origin;
  - no sign-in;
  - lockout behaving the same for real and made-up usernames;
  - TOTP replay and recovery-code reuse;
  - private events leaking through views, search or export;
  - seeing someone's location after they hid it, as another person and as the administrator;
  - health leaking unless shared;
  - feed addresses aimed at the house: loopback, private ranges, link-local, IPv6 forms, numeric and hex forms, a name that resolves to a private address, and a redirect to one;
  - path traversal on the static files;
  - upgrading a database from every older schema, with real old rows in it.
- Keep a local preview server that runs the real API code against made-up data (made-up positions and health too) so the app can be clicked through without touching real accounts.

## Deployment

- Package only the integration. Back up the config, the old integration and the database first. Run the configuration check, restart only when it's valid, and verify the result (version, logs, a real API call). Restore from the backup automatically if the check fails.
- Afterwards, write an API document (for a future phone app), a short admin guide (inviting people, devices, sharing) and a list of the remaining risks for the owner, such as anything about their wider setup that weakens the calendar's security.

Start with step 1: tell the owner what you can inspect yourself, then ask the rest.

Family calendar, at home

Convenient for the family, boring for an attacker.

The goal was a calendar the family enjoys using, where none of the data is anyone else's business: not an advertiser's, not a cloud's, and not a stranger's who found the sign-in page. The data lives on the Pi, the server decides who sees what, and the tests keep trying to break in.

Home Assistant integration·own sign-in + two-step·see it on the Wall panel