Privacy & data handling
What we store, how long, who we share it with, and what we explicitly don't store.
DevUpdate.io is a developer tool, so the trust bar is high. This page lists what we keep, what we don't, and the third parties involved.
What we store #
-
Account data: email, hashed password (or OAuth identity), username, timezone, digest preferences (cadence, send day and time, and the severity bar that decides which advisories email you immediately, including any per-lockfile override of it). We don't ask you to invent a username when you sign up: we derive one from the part of your email address before the
@(lightly normalized, and suffixed if that handle is already taken), and you can change it any time under Settings → Account Information. It is a display and sign-in handle, nothing more, and it never leaves your account except to team members you share a workspace with. See Your username for the details. If you sign up with GitHub and your primary email is set to private, GitHub gives us ausers.noreply.github.comaddress we can't deliver mail to, so we'll ask you for a reachable email before you can use the app, and verify it via a one-click confirmation link. -
Signup attribution: how you first arrived, recorded once when the account is created and never updated afterwards. Always: the page you landed on and, if another site sent you, its hostname. Additionally, if you arrived through a marketing link: the campaign parameters that link carried (
utm_source,utm_medium,utm_campaign,utm_term,utm_content) and one ad-network click identifier if present (e.g.gclid). We store the path you landed on, not the query string, and the referring site's host, not its full address: another site's URL can carry their own session tokens or your email address, and we don't want them. It is never sold, and it is deleted with your account. Arriving directly, with no referrer, means only the landing page is recorded; the campaign fields stay empty.It is first-party with one deliberate exception, and only with your consent: if you clicked one of our LinkedIn ads, we tell LinkedIn that the click became a signup. See LinkedIn conversion measurement below for exactly what that does and does not include.
To carry this from the page you landed on to the moment you create an account (possibly several clicks, or a trip through GitHub or Google sign-in, later), we set a single first-party cookie named
du_attrwhen you arrive from a campaign link or another site. It holds only the values listed above, expires after 30 days, and isHttpOnly, so no script on the page can read it. It is not set at all if you arrive directly.This one cookie is set before you answer the cookie banner, and is the only non-essential cookie for which that is true. We think that is the honest trade: gating it on the banner would not make it optional so much as make our own acquisition numbers a measure of who accepts banners. If you would rather it were not set, block cookies for this site. Nothing in the product depends on it.
Answering the banner writes a second cookie,
du_consent, holding nothing but which button you pressed (acceptedorrejected) for six months. Our backend cannot read the banner choice otherwise (it lives in your browser's local storage), and one server-side decision now depends on it (LinkedIn conversion measurement, below). Recording the answer is not a use of it, which is why the cookie is written either way. -
Source list: the GitHub URLs and vendor URLs you've added as tracked sources.
-
Tracker requests: if you ask us to build a deterministic parser for a source (the "Request a deterministic tracker" flow), we store the URL you submitted, the optional product name and note, and which account raised it, so we can prioritize by demand and follow up. These rows survive account deletion (the user link is nulled) as an anonymous demand signal.
-
Lockfile snapshots: package names and versions extracted from uploaded lockfiles. The original file is not retained.
-
Package→repository match cache: which GitHub repo publishes a given public package, cached platform-wide so the same package is resolved once rather than once per customer (see Package matching). A row holds the package name, its ecosystem, and what the public registry answered, and no link to any account, because the answer isn't specific to one. We only write a row when the registry confirms the package exists there, so a cached name is public by definition. A package name the public registry does not recognise (the shape an internal package from a private registry takes) is never written to this shared table; those are re-checked per upload instead. Your own lockfile's full package list stays in your account's own data, above.
-
Release analysis output: summaries, risk scores, and detected changes for each release we've processed. Cached so the AI never has to re-analyze the same diff twice.
-
Security-issue matches: for each monitored dependency, which public OSV/GHSA advisories affect the version you pin (advisory ids, severity, fix status, and the advisory's own workaround text). Derived entirely from your parsed package + version pairs and public advisory data. Each also records whether and when we emailed you about it. Refreshed daily while the lockfile is active; while it's deactivated they are kept as they were, neither refreshed nor shown, so reactivating it doesn't alert you again about an advisory you were already told of. Deleted when the issue resolves or the lockfile is removed.
-
Available upgrades: for each monitored dependency that trails its latest release, the version on offer, the risk signals across the range, and whether you dismissed it. Derived from your parsed package + version pairs and public release data. Recomputed while the lockfile is active; while it's deactivated they are kept as they were, neither refreshed nor shown, so a dismissal survives reactivating it. Deleted when the upgrade resolves or the lockfile is removed.
-
Public discussion metadata. For each tracked GitHub repo we periodically read its public open issues so the Pulse "News & discussions" section can surface ongoing problems. We store the issue title, URL, author handle, reaction/comment counts, labels, and a short excerpt of the body (public data only). Private-repo issues are only read when you've explicitly installed the DevUpdate.io GitHub App on the repo (read-only permissions, revocable from GitHub).
-
Billing & usage metering: subscription tier, billing cadence, invoice history, AI-usage events (token counts per operation, kept for internal cost monitoring and fair use, never billed per use), and the EU CRD §16(m) withdrawal-waiver consent record (timestamp, IP, terms version). When a paid team changes hands, a record of the handover: the account handing it over and the one taking it over (and the account that paid, if that was someone else), when it was asked for and how it ended, the subscriptions it moved between, the refund owed to the previous owner, or to a payment that couldn't complete it, and whether our payment provider has carried it out, and, for a charge we settle by hand, the order and amount our payment provider reported.
-
Authentication tokens: RS256-signed JWT access tokens (1 h) and refresh tokens (30 d), plus hashed API tokens for programmatic access. Refresh-token reuse is detected via a per-session JTI; a detected replay invalidates every active session for the affected account.
-
Connected AI clients (hosted MCP server): when you authorize an MCP client (Claude, Cursor, etc.) via OAuth, we store the client's self-registered metadata (name, redirect URLs), the scopes you approved, and SHA-256 hashes of the issued access and refresh tokens (never the raw token values), plus created/last-used timestamps so the Settings list can show you what's connected. Revoking a client from Settings → Connected AI Clients deletes its access immediately; consent grants and revocations are recorded in the audit log. A password reset, a password change and signing out of every session revoke every connected client at once, access and refresh tokens alike; you then reconnect your AI clients. None of the three touches your API token: it keeps working until you revoke or regenerate it in Settings, so do that too if you think it was taken.
-
GitHub App user access token (when you connect GitHub): a short-lived (8 h) user-to-server token, plus its refresh token, stored against your account row, Fernet-encrypted at rest with a key that lives in the runtime environment, not the database, so a DB snapshot alone doesn't surface the tokens. The backend can call GitHub as you and let GitHub's own permission model decide which repositories we can read. The token is issued by the DevUpdate.io GitHub App with read-only access to repository contents and metadata (no write, ever) and scoped to repositories you explicitly grant when installing the App. The exact GitHub scopes we request are:
- Repository permissions:
contents: read,metadata: read - Account permissions:
email addresses: read
No other repository, organisation, account, or write permission is ever requested. Both tokens are deleted when you disconnect GitHub or delete your account. Revocable at any time from github.com → Settings → Applications. Revocation from GitHub takes effect on our next call without any action on our side.
The Repositories with access panel in the Lockfiles tab lists the repos you've granted the App by calling GitHub's user-installations API as you at view time. This needs no additional scope beyond the read-only access above, and the list itself is not stored: it's fetched on demand and only briefly cached server-side to stay within GitHub's rate limits.
- Repository permissions:
-
Session metadata: IP and User-Agent per session for security audit, with a 30-minute idle timeout and a 3-concurrent-session cap.
-
Audit log: an append-only
audit_eventstable records security-relevant actions on your account (login success/fail, logout, refresh-token reuse detection, password reset, password change, account deletion, AI-client MCP authorizations and revocations) with timestamp, IP, and user-agent. The IP and user-agent are kept for 90 days, then cleared by a daily sweep; the entry itself, its time and what it records stay. Entries outlive the account they describe, because an audit trail that disappears with the account is no use for investigating what happened to it, so none of them holds an email address. Deleting your account unlinks your entries from it. A failed sign-in records only the domain of the address that was typed (example.com, notyou@example.com), and nothing at all of a typed username, since those rows belong to no account and could never be deleted with one. The entry recording an account's deletion keeps only the time and the address's domain: no account number, IP address or user agent. Used internally for incident review; not shared. -
Email send log: an append-only
email_send_logtable records each transactional or lifecycle email we hand to AWS SES for delivery (verification, password reset, the day-4 unverified warning, and the inactive-account nudges described under Retention below), with the email type, a timestamp, and the domain of the recipient address:example.com, notyou@example.com. The domain is what the abuse defense actually reads (a wave of signups on one throwaway provider, or one provider's deliverability failing) and it does not identify you. Storing the full address would have, and this table carries no account reference, so nothing in the deletion path could have reached those rows: the address would have outlived the account it belonged to. The row reflects that SES accepted the send request, not that the message reached your mailbox (we don't track bounces or opens here). Used internally for abuse defense and to confirm our lifecycle email loops dispatched; not shared.
How tenant isolation is enforced #
Every table that holds user-owned data (tracked sources, lockfiles,
share links, OAuth connections, sessions, notifications, your audit log,
your connected AI clients, and your billing and usage records) is
isolated by two layers. First, requests from your authenticated dashboard
and API scope every query against these tables to your own user id. The
one deliberate exception is a share link you
create, an unauthenticated token that exposes only the specific summary
you chose to share, and the repository's name only if you chose to show it
(it is left out by default). Second, as defense in depth, those tables carry a
Postgres Row Level Security policy: endpoints that serve your own data
bind your user id to a Postgres session setting, and RLS then narrows
each tenant-scoped table to your own rows, so a bug that forgets a
user_id filter is downgraded from "another tenant's data leaks" to
"the query returns nothing".
A small number of endpoints deliberately read across that boundary and so cannot bind a single user id: the team-wide views described below, and our own operator diagnostics. Those rely on the first layer (an explicit ownership check on every query) rather than the second, which is why the first layer is the one we treat as load-bearing and the second as the backstop beneath it.
Two boundaries are drawn by team rather than by individual, because the data is genuinely shared: your team's record itself, and the AI fair-use credits that pool across your team's seats. Those are scoped to your team in the application layer: a member of one team can never read another team's. A team handover's record is shared by the two people it concerns, the owner handing the team over and the admin taking it over, and the team page shows it to them and to nobody else, checked the same way. And records that outlive an account by design (billing documents we're required to retain, and audit entries) stay isolated after the account is closed: they are readable by no tenant at all rather than falling back into general visibility.
Separately, and layered on top of that, you can control which of your own
lockfiles your own AI agents see: each lockfile has an Available to AI
agents toggle that, when off, excludes it from the
MCP server's get_upgrades and get_security_issues tools
while keeping it on your dashboard. This is a
user-controlled display/scope preference within your account, not the
tenant-isolation mechanism above, and not a limit on your own full-access
API token used directly. It stores no new personal data: it's a single
boolean on a lockfile row you already own, so the What we store list is
unchanged.
When an agent scopes a call to the repository it's working in (the tools'
repo argument), it sends us that repository's owner/name. We use it to
scope that one call against lockfiles you've already connected, and don't
store it: the server-side analytics event for an MCP call records the tool's
name and your pseudonymous account id, never its arguments. Our skills send
only those two parts, never the repository's remote URL, because a remote URL
can embed a credential; if an agent passes a URL anyway, we read the
owner/name out of it, keep none of it, and never repeat it back. The
self-hosted stdio server applies the filter on your own machine, so there
the name never reaches us at all.
Shared, non-tenant tables #
Two tables are deliberately community-wide rather than per-tenant, because what they hold is a fact about a public package or repository rather than about any account: the source catalog behind Explore (one row per source, watched by many users) and the package→repository match cache described above. Neither carries a user id. The paragraphs below cover how a private repo you track is kept out of every public read path.
The source catalog behind Explore is shared across the whole community (a single row per source, watched by many users), so it is not a per-tenant table. A private GitHub repository you track (via the GitHub App) is flagged private on that shared row and excluded from every public read path: it never appears in the explorer list or search, and the explorer's detail page and release-summaries feed return "not found" for a private source regardless of who asks (the public explorer never serves it, not even to you; authorization there isn't the point, the source simply isn't part of the public catalog). The anonymous realtime stream likewise refuses to subscribe to it. You still see the repository and its releases on your own dashboard. In short: tracking a private repo on DevUpdate.io never publishes its name, description, or release notes.
What we don't store #
- Lockfile raw bytes. Parsed and discarded; only package + version pairs persist.
- Source code. We never clone repositories. Diff analysis goes through GitHub's compare API and the result is summarized; the raw diff is not stored.
- OAuth tokens beyond what we need. We hold the minimum permissions required to power the features you use. Google sign-in stays at basic profile + email. GitHub sign-in is handled by the DevUpdate.io GitHub App, whose user-authorization flow grants read-only access to repository contents and metadata, scoped to repositories you select when installing the App on your account or org. There is no "full control" or write permission anywhere in the request, and authorization alone (without an install) is enough for public-repo features. We only call GitHub for something you set up (adding a source, connecting or syncing a lockfile, tracking the packages in it), and each call is restricted to the repositories involved: the repo you named, or the upstream repo a package in your lockfile points at. That includes packages a sync adds and tracks automatically: matching one to a private repository checks your own access with your OAuth token first, exactly as when you select it by hand.
- Anything beyond parsed dependencies from connected repos. When you use a live lockfile connection, we read only the connected lockfile and store only the package + version pairs parsed from it, never the raw file or other repository contents.
- Cookies for live updates. While you're signed in, your browser keeps a server-sent-events connection open so subscription changes and new releases appear without a reload. It authenticates with the same session token as every other request and adds no cookie. Public source pages in Explore open a connection of their own while you view one, whether or not you're signed in; that one is tied to no account, sends no token, and sets no cookie.
LinkedIn conversion measurement #
We run ads on LinkedIn and need to know whether they work. When you click one
of our LinkedIn ads, LinkedIn appends its own click identifier (li_fat_id)
to the link you follow. If you go on to create an account and you accepted
the cookie banner, our server tells LinkedIn that this click became a signup.
- Sent: LinkedIn's own click identifier, a timestamp, and an internal account number.
- Not sent: your email address. LinkedIn accepts a hashed email and it would measurably improve their match rate; we don't send it, because a hashed email is our data about you and lets LinkedIn match against their whole member graph, while their click id only ever concerns someone who already clicked our ad. Also never sent: name, employer, job title, country, or anything about your dependencies, lockfiles or code.
- Who it can concern: only people who clicked a LinkedIn ad of ours. With no click identifier there is nothing to send, and nothing is sent.
- If you reject the banner: nothing is sent and the ad goes unmeasured.
This runs server to server, on our backend. There is no LinkedIn Insight
Tag: no ad-network pixel of any kind on this site, and none in the
signed-in app, and no ad-network cookie. It reads two records already
described above: the du_attr attribution cookie, and the du_consent record
of how you answered the banner.
Third parties #
| Service | What they see |
|---|---|
| OpenAI (LLM, currently the only provider) | Diff text + release notes for analysis, plus public marketplace metadata (extension manifest, install count, publisher info) and source URLs/descriptions when we synthesize a trust score for a VS Code / Open VSX extension or an ad-hoc web source. Not used for training per OpenAI's API terms. We may engage other LLM providers in the future. |
| OpenSSF Scorecard (api.securityscorecards.dev) | Public GitHub repo identifier (owner/name) when computing a trust score. Anonymous public API; we send only the project identifier, never any of your data. |
| Google deps.dev (api.deps.dev) | Public package name + ecosystem (npm, PyPI, Go, Cargo) when mapping a lockfile dependency to its upstream GitHub repository: during source discovery when the package doesn't otherwise resolve to a GitHub repo (its registry metadata lists no GitHub link, or its Go module path isn't on GitHub), and when computing a trust score. Anonymous public API; we send only the package identifier. The discovery hop is only taken for packages not already in the platform-wide match cache. |
| OSV.dev (api.osv.dev) | Public package name + ecosystem when counting open advisories for a trust score, and package name + the pinned version from your lockfile when verifying the security flag on an available upgrade. Anonymous public API; we never send your account identity, repo names, or the rest of the lockfile. |
| Public package registries (registry.npmjs.org, pypi.org, crates.io, packagist.org, Go module proxy) | Public package names when discovering a dependency's source repository (see Package matching), and, for npm/PyPI, when validating that an offered upgrade version was actually published. Anonymous public APIs; only the package identifier is sent. The published-version check reads a short-lived shared cache of just that package's version list, so a recompute re-requests it only for packages whose entry has expired or was never fetched, rather than for every dependency. Because the discovery result is cached platform-wide, a given package name is sent for discovery roughly once per refresh window across all customers, not once per upload. |
| Google (sign-in OAuth) | Email, name, profile picture when you sign in with Google. |
| GitHub (sign-in via DevUpdate.io GitHub App) | Email, username, avatar URL. Sign-in issues a short-lived user-to-server access token (plus refresh token) carrying read-only access to repository contents and metadata for any repos where you've installed the App. Tokens are stored against your account row and deleted on disconnect. |
| GitHub App (per-repo install, required for private repos) | The same DevUpdate.io GitHub App must be installed on repos (or whole orgs) you want private-repo features for: starred-sync of private stars, live lockfile sync from private repos, source-tracking of private repos. You pick which repos at install time; access stays read-only (contents + metadata only) and is revocable from GitHub settings. We store only the parsed dependency list for live lockfile connections. |
| Railway (hosting) | Hosts everything that runs DevUpdate.io: the website, the application server, the scheduled jobs, the PostgreSQL database that holds the data on this page, and the Redis cache, all in Railway's EU West region (the Netherlands). Railway is a US-based company. |
| AWS (Amazon SES and Route 53) | SES sends our outbound email (digests, alerts, transactional mail) from AWS's Frankfurt region, so it receives the recipient address and the message. Route 53 answers DNS lookups for devupdate.io and sees no account data. AWS does not host the application or its database. |
| PayPro Global | Your account email address and username, sent when we open a checkout so the payment form arrives pre-filled, plus the billing details you then enter there (name, billing address, payment method). PayPro is the merchant of record and handles the payment itself: card numbers are entered on their form and never reach us. |
| PostHog (EU-hosted) | Product analytics: page views, feature usage, clicks on buttons and links (recorded with their text and attributes, link address included, and those of the elements around them; a click on any button or link naming one of your repositories, by its owner/name or a GitHub link to it, is not recorded at all, and any other element that shows a repository in what a click records is marked as such in our code and has its text and attributes removed in your browser before the click is sent, as is a link to an account or repository on GitHub, GitLab, Bitbucket, Codeberg, SourceHut or Gitee anywhere in it, and most other text shaped like a repository's owner/name, keeping the word pairs our own pages use, such as "CI/CD", and media types such as image/webp), session recordings (only after you accept analytics in the cookie banner: your browser records the layout of the pages you visit, your clicks and your navigation, with every piece of page text, every input value and text attributes such as tooltips masked before anything is recorded, and GitHub account and repository names removed from link and image addresses; the page and request addresses a recording keeps are filtered like those on every event, so no one-time token in a link is recorded; whether a recording runs at all also depends on our PostHog project's recording setting, and withdrawing consent stops one in progress), and the campaign parameters (utm_*) and ad-click identifiers that browsers send by default on a marketing link. Pseudonymized by account ID: your email address and username are not sent, so a person profile there is an account number and the events attached to it. URLs are filtered before they leave your browser rather than sent verbatim, on every event type. Only the campaign parameters and click identifiers named above survive; anything else in a query string is dropped, and the one-time token in a password-reset, email-verification, email-change, account-deletion, team-invite or share link is removed with it, so those never reach PostHog. Never shared externally and never sent to ad networks. The LinkedIn report described above is built from our own database, never from anything PostHog holds. Most events are captured in your browser and honor the cookie-banner choice; a small set of server-side events (account signup, a lockfile being added, a digest email being sent, and an AI agent invoking the MCP tools) are captured cookielessly on our backend as operational product analytics (no browser storage). Beyond the pseudonymous account id, the only extra data on those is the campaign label on the signup event, described under Signup attribution below. Both feed the same EU-hosted project. Deleting your account does not delete the PostHog profile today: PostHog keeps the account number and the events attached to it, which no longer match anything we hold, under the data retention of our PostHog project. To have it removed as well, email info@devupdate.io from your account address before you delete the account; once the account is gone we can no longer tell which profile was yours. |
| LinkedIn (ad conversion measurement) | Only when you arrived by clicking one of our LinkedIn ads and accepted the cookie banner: on signup we send LinkedIn back its own click identifier (li_fat_id), a timestamp, and an internal account number, so it can report that the ad converted. Server-to-server; no Insight Tag or other LinkedIn code runs on the site. Your email address is never sent, hashed or otherwise. See LinkedIn conversion measurement. |
| Source icons (your browser, not our backend) | To show a logo next to each source, your browser loads a small image directly: a repo owner's public avatar from github.com/<owner>.png for GitHub sources, the extension icon from the VS Code Marketplace's own gallery CDN (*.gallery.vsassets.io) for Marketplace sources, or /favicon.ico from any other web source's own domain. Only the owner name, extension id, or hostname appears in the image URL, the referrer is suppressed, and no account data is sent. These requests go straight from your browser to the source's own platform: no new third-party service is involved, and they fall back to a plain initial if the image can't be loaded. |
Retention #
- Active accounts: all data retained.
- Unverified email/password signups: the row is removed automatically 7 days after signup if the email is still unverified. A reminder email goes out around day 4. OAuth signups are created already verified and are never affected by this sweep.
- Inactive verified free-tier accounts: if you signed up but never added a source and never connected a lockfile, the account is removed automatically 30 days after signup. We send up to five reminder emails over the preceding 27 days, the last of which is an explicit deletion warning. Paid-plan accounts and team members are excluded; adding a source, upgrading, or joining a team takes you out of this cohort.
- Closed accounts: see Account → Deletion. Account-deletion is self-serve and removes personal data within 30 days, with cryptographic confirmation. The one copy it does not reach is the pseudonymous PostHog profile (an account number and its events), which PostHog keeps under our project's data retention; ask us before deleting the account if you want that removed too (see the PostHog row under Third parties).
- Billing record retention obligation: the payment notifications PayPro sends us for each order and subscription change (stored as our order and subscription-event records, with the billing details they carry) are booking vouchers under German tax and commercial law (§147 AO, §257 HGB), so we keep them for 8 years, counted from the end of the calendar year they arrived in, and a daily sweep deletes them once that period has ended. Two things move that end later. A subscription's records are never deleted while it is still running, however old they are (a cancellation still running to the end of its paid period counts), because renewals and refunds rest on its first order. Once it has ended, the 8 years count from the end of the calendar year of the last change we recorded on it, normally the year it ended, when that is later than the year the record arrived. Deleting your account does not remove them earlier. Subscription history and withdrawal-waiver consent are stored on the subscription record itself, which is kept while the subscription is running and, once it has ended, for 10 years, counted from the end of the calendar year of the last change we recorded on the subscription (normally the year it ended); a daily sweep then deletes it. Deleting your account neither shortens nor extends that period. The record of a paid team's handover is kept with the record of the subscription the team moved away from, and deleted with it; deleting either account unlinks it from the record rather than removing it. These records are reduced to the minimum required by law.
Data export #
You can export a JSON dump of your account data (sources, lockfiles,
analysis history, billing) on request. Email info@devupdate.io from
your account address. It's a manual job today, not yet self-serve.