@decartai/cf-access
v0.2.1
Published
Cloudflare Access middleware for Decart internal apps hosted on Vercel. Verifies the Cf-Access-Jwt-Assertion JWT and fails closed.
Downloads
654
Maintainers
Readme
@decartai/cf-access
Locks a Decart internal app (Next.js on Vercel) behind our Cloudflare Access
front door. Add two lines to your app and only people signed in with a
@decart.ai Google account can reach it.
Add it to your app
npm install @decartai/cf-accessCreate proxy.ts at the root of your project (next to app/ — or inside
src/ if your app uses a src directory):
export { proxy } from '@decartai/cf-access/next'On Next.js 13.4–15, the file is middleware.ts and the export is middleware
(same function — Next 16 renamed the convention):
export { middleware } from '@decartai/cf-access/next'When upgrading an app to Next 16+, rename the file — a leftover
middleware.ts will eventually be ignored by Next, silently reopening
direct *.vercel.app access.
That's it for the code. To put the app on its internal address, follow the Exposing an internal app checklist below.
What you get
- Every production request must carry a valid Cloudflare Access JWT — the
cryptographic proof that the visitor signed in through
https://<your-app>.decart.dev. Requests that sneak in through<project>.vercel.appor any other side door get a 401. - Fail closed: if Cloudflare isn't in front yet or something is misconfigured, the app is blocked, never silently public.
next devworks normally (no auth locally).- Vercel preview deployments work normally — they're protected by Vercel Authentication instead (see below), so PR previews don't need Cloudflare.
- Your app learns who's signed in for free:
import { headers } from 'next/headers'
import { getAccessViewer } from '@decartai/cf-access'
const viewer = getAccessViewer(await headers())
// viewer?.email === '[email protected]'The x-decart-user-email / x-decart-user-id request headers are set by the
middleware after verification and can't be spoofed from outside (incoming
values are stripped on every request the middleware sees). If your app defines
its own config matcher, routes excluded from it never get that stripping —
on those routes use verifyAccessViewer(await headers()) instead, which
re-verifies the JWT cryptographically.
Exposing an internal app: the full checklist
- Vercel → your project → Settings → Domains: add
<name>.decart.dev. - Vercel → Settings → Deployment Protection: Vercel Authentication ON, scope Standard Protection (free — this is the team default for new projects; just confirm it's on).
- Cloudflare (decart.dev zone): add a proxied CNAME
<name>→cname.vercel-dns.com. - Install this package + the two-line
proxy.ts, deploy. - Open
https://<name>.decart.devin a private window — you should get the Google login, then the app. Then try the project's*.vercel.appURL — you should get a 401 or a Vercel login, never the app.
Pick a name that isn't already used by a Twingate-internal service — check the reserved-names list in the internal docs before claiming a subdomain.
Things that need extra care
- OAuth callbacks / absolute URLs: update them to
https://<name>.decart.dev. - Inbound webhooks (Stripe, Slack, GitHub…): they can't pass Google SSO.
Either give the route a
publicPathsentry (below) and rely on the provider's own signature verification, or create a Cloudflare Access service token and send its headers with the webhook if the provider supports custom headers. - CI / cron / app-to-app calls: use a Cloudflare Access service token
(Zero Trust → Access → Service Auth). Send
CF-Access-Client-Id/CF-Access-Client-Secretheaders with requests to thedecart.devaddress; Cloudflare exchanges them for a JWT and this middleware accepts it. The app sees the token's name inx-decart-service-token.
Customizing
// proxy.ts (or middleware.ts on Next ≤15, exported as `middleware`)
import { createAccessMiddleware } from '@decartai/cf-access/next'
export const proxy = createAccessMiddleware({
// Routes served without Decart SSO. A string matches the path and any
// subpath. Only do this for routes that verify the caller themselves
// (e.g. webhook signature checks). '' and '/' are rejected at startup;
// anchor your RegExps.
publicPaths: ['/api/webhooks/stripe', /^\/healthz$/],
// Set false if this project can't guarantee Vercel Deployment Protection
// is enabled: preview deployments then also require the Access JWT.
allowPreviews: true,
})Next.js only honors a config matcher written literally in your own
proxy.ts/middleware.ts. The default (no config) runs on every route,
which is the safe choice. If you must exclude paths, prefer publicPaths
over a matcher — excluded-by-matcher paths skip the identity headers too.
Environment variables
| Variable | Effect |
| --- | --- |
| CF_ACCESS_TEAM_DOMAIN | Override the baked-in team domain (slug or full domain). |
| CF_ACCESS_AUD | Override the baked-in Access application AUD tag. |
| DECART_ACCESS_DISABLED=1 | Skip all checks — for running a production build locally. Ignored on Vercel (any deployment with VERCEL/VERCEL_ENV set), so a copied .env can't unlock production. |
How it works
you ── https://name.decart.dev ──► Cloudflare edge
│ Access: Google SSO (@decart.ai)
│ adds Cf-Access-Jwt-Assertion (signed JWT)
▼
Vercel ──► this middleware
│ verifies JWT signature against
│ the team's public JWKS + issuer + AUD
▼
your app (+ x-decart-user-* headers)Anyone hitting Vercel directly (the *.vercel.app URL, curl --resolve
tricks, etc.) has no way to mint that JWT — only Cloudflare's private keys can
sign it — so the middleware rejects the request. Verification happens in Edge
Middleware with jose; the JWKS is fetched
once per runtime and cached.
Environments:
| Environment | Behavior |
| --- | --- |
| next dev | Allowed (no auth). |
| Vercel preview | Allowed by this middleware; gated by Vercel Authentication upstream (Vercel's check runs before middleware, so reaching the app already required Vercel SSO). |
| Production | Valid Access JWT required on every host and route. |
Maintainers
DEFAULT_TEAM_DOMAINandDEFAULT_AUDinsrc/constants.tsare the org's Zero Trust team slug and the wildcard*.decart.devAccess application's AUD tag. If that Access app is ever recreated, updateDEFAULT_AUDand release. With an empty AUD the middleware fails closed with a 500 — nothing is ever silently open.- Release:
npm run release— bumpp prompts for the version, commits, tags, and pushes. Thev*tag triggers.github/workflows/release.yml, which publishes to npm via trusted publishing (GitHub OIDC — no npm token anywhere), then generates the GitHub release notes withchangelogithub.prepublishOnlyruns the typecheck, tests, and build. The package is public on purpose — it contains no secrets: verification uses Cloudflare's public keys, and the team domain is visible to anyone who hits the login redirect. - Rotating/renaming the Access app changes the AUD → bump
DEFAULT_AUDand release; apps pick it up on their next dependency update, or immediately via theCF_ACCESS_AUDenv var.
