A single-tenant Microsoft Entra credential-expiry monitor. Vigiltra fetches every app registration and SAML enterprise app from Microsoft Graph, caches them in Cloudflare D1, and sends a daily digest to your notification channels before secrets or signing certificates expire.
Runs as a SvelteKit app on a Cloudflare Worker. No servers to patch, no cron box, no database to back up.
- App registration client secrets (
passwordCredentials) - App registration certificates (
keyCredentials) - SAML enterprise app signing certificates (
servicePrincipalswithpreferredSingleSignOnMode eq 'saml'). Merged into their matching app registration byappId, with the preferred signing cert marked in the UI. - Superseded credentials: when a provider adds a new certificate without deleting the old one, Vigiltra flags the older one as superseded and suppresses its notifications.
| Layer | Tech |
|---|---|
| Runtime | Cloudflare Workers (paid plan required for D1 + cron) |
| Framework | SvelteKit 2 + Svelte 5 (runes) + @sveltejs/adapter-cloudflare |
| Database | Cloudflare D1 (SQLite) via Drizzle ORM |
| Auth | Cloudflare Access (Entra IdP) — JWT verified per request |
Cloudflare Email Workers (send_email binding) |
|
| Telegram | Bot HTTP API |
| Graph auth | Client-credentials flow (separate Entra app with Application.Read.All) |
| Cron | Workers Cron Triggers → /__cron endpoint |
- Cloudflare account on the Workers Paid plan (D1 + cron triggers)
- A custom domain in Cloudflare (for Access to work;
*.workers.devURLs can't be protected by Access) - A Microsoft Entra tenant where you can create app registrations and grant admin consent
pnpm≥ 9 and Node ≥ 20 locally
git clone <this repo> cd vigiltra pnpm install
pnpm wrangler d1 create vigiltra-db
Copy the returned database_id — you'll need it in wrangler.jsonc.
wrangler.jsonc is gitignored because it pins your Cloudflare account and D1 IDs. Create it from this template.
⚠
workers_devandpreview_urlsare bothfalse. Cloudflare Access can only protect your custom domain route — the default<worker>.<account>.workers.devhostname and preview URLs bypass Access entirely, exposing the whole app unauthenticated. Leave both off. (If you've already deployed once with them enabled, also disable them in the dashboard: Workers & Pages → the worker → Settings → Domains & Routes → disable theworkers.devsubdomain and preview URLs.)
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "vigiltra",
"account_id": "<YOUR_CLOUDFLARE_ACCOUNT_ID>",
"compatibility_date": "2026年04月20日",
"compatibility_flags": ["nodejs_als"],
"main": ".svelte-kit/cloudflare/_worker.js",
"assets": {
"binding": "ASSETS",
"directory": ".svelte-kit/cloudflare"
},
"workers_dev": false,
"preview_urls": false,
"d1_databases": [
{
"binding": "DB",
"database_name": "vigiltra-db",
"database_id": "<FROM_STEP_2>",
"migrations_dir": "./drizzle"
}
],
"send_email": [{ "name": "SEND_EMAIL" }],
"triggers": {
// Daily at 09:00 UTC — refresh Graph cache, then send notification digest
"crons": ["0 9 * * *"]
}
}pnpm wrangler d1 migrations apply vigiltra-db --remote
For local development:
pnpm wrangler d1 migrations apply vigiltra-db --local
In Entra admin center → App registrations → New registration:
- Name:
Vigiltra Graph Reader - Single tenant
- No redirect URI
Then:
- API permissions → Add a permission → Microsoft Graph → Application permissions → add
Application.Read.All. Remove the default delegatedUser.Read. Click Grant admin consent. - Certificates & secrets → New client secret. Copy the Value (not the ID).
- Overview → copy the Directory (tenant) ID and Application (client) ID.
- In the Cloudflare dashboard, add your custom domain and create a route for the Worker (e.g.
vigiltra.example.com). - Zero Trust → Access → Applications → Add an application → Self-hosted.
- Application domain: your route from step 1.
- Identity provider: add Entra ID (OIDC) with a second Entra app registration using delegated
openid profile emailscopes. (This is separate from the Graph reader.) - Create an Access policy that allows your users.
- Open Configure on the application → Basic information → copy the AUD tag (64-char hex).
- Note your team domain (e.g.
acme.cloudflareaccess.com).
Vigiltra reads six Worker secrets. Each is set with pnpm wrangler secret put <NAME>, which prompts for the value and stores it encrypted on Cloudflare. Secrets are never bundled into the Worker script.
The Entra tenant (directory) GUID. Used in the OAuth token endpoint URL when Vigiltra requests an access token.
Where to find it: Entra admin center → App registrations → open the Graph Reader app created in step 5 → Overview tab → copy Directory (tenant) ID.
pnpm wrangler secret put GRAPH_TENANT_ID
# paste the tenant GUID, e.g. d733d662-fcff-4bbd-82f8-f804202f357eThe Graph reader app's client/application ID.
Where to find it: same app's Overview tab → Application (client) ID.
pnpm wrangler secret put GRAPH_CLIENT_ID
The client secret Vigiltra presents to Entra to obtain an app-only Graph token.
Where to get it: in the Graph reader app → Certificates & secrets → Client secrets tab → + New client secret. Give it a description and an expiry (max 24 months), click Add, then immediately copy the Value (not the Secret ID). It will never be shown again — if you miss it, create another one and delete the first.
⚠ Set a calendar reminder to rotate this before it expires. Vigiltra can't monitor its own Graph reader secret (the Worker would lose Graph access first).
pnpm wrangler secret put GRAPH_CLIENT_SECRET
The 64-character hex "Application Audience" tag. Cloudflare Access signs a JWT with this aud claim; Vigiltra's hooks verify it matches before serving any request.
Where to find it: Cloudflare dashboard → Zero Trust → Access → Applications → click Manage (or Configure) on your Vigiltra app → switch to the Additional settings tab → click the AUD tag chip in the row of setting chips. Copy the Token value — a 64-character hex string under "Application Audience (AUD) Tag".
Note: the AUD tag is not the same as the OIDC application ID shown in Entra. It lives only inside Cloudflare.
pnpm wrangler secret put ACCESS_AUD
Your Cloudflare Zero Trust team domain, used to fetch the JWKS for JWT verification. Format: <team>.cloudflareaccess.com (no scheme, no trailing slash).
Where to find it: Cloudflare dashboard → Zero Trust → Settings → Custom Pages (or General) → the Team domain field. Example: acme.cloudflareaccess.com.
pnpm wrangler secret put ACCESS_TEAM_DOMAIN
# e.g. acme.cloudflareaccess.comA shared secret the scheduled handler passes to /__cron as the x-cron-secret header. Prevents anyone from triggering a digest run by POSTing to that URL.
How to generate:
openssl rand -hex 32
Then:
pnpm wrangler secret put CRON_SECRET
# paste the 64-char hex valueYou don't need to share this with anything — the scheduled handler in the same Worker reads it from env.CRON_SECRET and forwards it as the header.
pnpm wrangler secret list
Should show all six names (values are never displayed).
Just run pnpm wrangler secret put <NAME> again with the new value. The old value is replaced atomically on the next cold start.
pnpm run deploy
Then visit your Access-protected URL and click Refresh now to populate the cache.
- Copy
.dev.vars.exampleto.dev.varsand fill in the same secrets. pnpm wrangler d1 migrations apply vigiltra-db --localpnpm dev
In dev mode, hooks.server.ts bypasses Cloudflare Access and injects a synthetic Dev User.
| Command | What it does |
|---|---|
pnpm dev |
Vite dev server with HMR |
pnpm build |
Build and wrap the Worker entrypoint |
pnpm run deploy |
Build + deploy to Cloudflare |
pnpm check |
Type-check (wrangler types + svelte-check) |
pnpm lint |
Prettier + ESLint |
pnpm format |
Prettier --write |
pnpm test |
Vitest unit tests |
pnpm db:generate |
Regenerate SQL migration from schema.ts |
pnpm db:migrate:local |
Apply migrations to local D1 |
pnpm db:migrate:remote |
Apply migrations to remote D1 |
pnpm db:studio |
Open Drizzle Studio |
At 09:00 UTC the scheduled handler POSTs to /__cron with the x-cron-secret header. The endpoint:
- Calls
refreshGraphCache— pulls/applicationsand/servicePrincipals(SAML-filtered) in parallel, merges them byappId, computessupersededflags per app, and replaces the cache in a single D1batch()transaction. - Calls
runDigest— loads each monitored app's threshold schedule and notification channels, and sends one combined message per channel.
Each template is a { fires: number[], notify_past_expiry: boolean }. fires is a list of "days until expiry" values. If a credential's days-until-expiry equals any value in the list, it fires today. Built-in templates: Default, Aggressive, Minimal. Users can create custom templates and pin a default via the Settings page.
Any app can override the default template and/or notification channels. Monitoring can also be toggled off entirely per app.
- Email — sent via Cloudflare's
send_emailbinding.frommust be a verified destination/sender in your account. - Telegram — bot token + chat ID. Test both with the Send test button on the Channels page.
During refresh, for each app Vigiltra finds the latest endDateTime among each credential kind (secret / certificate). Any same-kind credential with an earlier endDateTime is stamped superseded = true. These are:
- Skipped by the digest
- Rendered with strikethrough in the UI
- Excluded from the "Expiring" and "Expired" filters
This prevents noisy repeat notifications for apps like 3CX that don't delete old signing certs when rotating.
Defined in src/lib/server/db/schema.ts. Migrations live in drizzle/.
| Table | Purpose |
|---|---|
app_registrations |
Graph cache: one row per app (merged across /applications and SAML SPs) |
credentials |
Graph cache: secrets + certs, composite PK (app_object_id, key_id) |
app_reg_overrides |
Per-app template/channel overrides, monitoring toggle |
threshold_templates |
Notification schedules (built-in + user-created) |
notification_channels |
Email / Telegram configs |
global_config |
Singleton row (id = 1): default template + global channels |
notification_runs |
Per-day digest run log (idempotency + summary) |
refresh_status |
Singleton row (id = 1): last-refresh metadata |
- Cloudflare Access protects every request. JWTs are verified against the Access team-domain JWKS (no shared secrets in the hot path).
- Graph credentials: stored as Worker secrets, never bundled, never logged. The sanitizer in
+page.server.tsredacts long hex/UUID strings from error messages shown in the UI. - Telegram bot tokens are never returned to the browser; channel configs are projected through
toPublicChannelwhich strips secrets. - Cron endpoint requires a timing-safe comparison of the
x-cron-secretheader againstCRON_SECRET.
500 Cloudflare Access is not configured— theACCESS_AUDorACCESS_TEAM_DOMAINsecret is missing in production.- Refresh says "failed" with a Graph 403 — the Graph reader app is missing
Application.Read.Alladmin consent.