diff --git a/qr-device-login/README.md b/qr-device-login/README.md
new file mode 100644
index 0000000..6b048b6
--- /dev/null
+++ b/qr-device-login/README.md
@@ -0,0 +1,97 @@
+# @lilith/qr-device-login
+
+Reusable QR-based cross-device session transfer. Enterprise-grade: mTLS admin
+API, public scan landing, in-memory token store with TTL + DoS cap.
+
+Follows the RFC 8628 device-authorization-grant shape adapted for same-user
+session transfer between devices of an already-logged-in account.
+
+## Why a service, not a library?
+
+The previous implementation was a self-contained route module vendored into two
+feature backends (`@features/my`, `@features/admin`). It worked, but any new
+consumer had to own the code — which meant `qrcode` as a dep, in-memory state
+per process, and drift between forks.
+
+This app owns **only** token lifecycle, QR encoding, and scan landing.
+Session minting stays with the consuming app — each consumer mints its own
+cookie after exchanging a one-time code over mTLS.
+
+## Architecture
+
+```
+Desktop (logged in) qr-auth.lilith.live Consuming app
+ │ │ │
+ │ 1. POST /api/device-link/ │ │
+ │ start ─────────────────────────────────────────────▶ createSession
+ │ │ ◀── mTLS ──────────────── (client SDK)
+ │ │ │
+ │ ◀── {id, qrDataUrl} ────────────────────────────────────── │
+ │ render QR + poll │ │
+ │ │ │
+ │ │ │
+ Phone scans QR ─▶ GET /scan/:token │ │
+ │ mark scanned, mint code │
+ │ 302 callbackUrl?id=&code= │
+ │ ─────────────────────────▶ callback handler
+ │ │ ◀── exchangeCode
+ │ │ ── mTLS ──▶
+ │ burn token, return metadata │
+ │ ─────────────────────────▶ createToken()
+ │ │ setSessionCookie()
+ │ │ 302 /
+```
+
+**Invariant**: the service never sets a cookie for any consuming domain.
+It only confirms "this scan was real" and hands back opaque metadata.
+
+## Packages
+
+| Package | Purpose | Published |
+|---------|---------|-----------|
+| `server/` | Bun HTTP service (private) | No |
+| `shared/` → `@lilith/qr-device-login-protocol` | Wire types shared by server + client | Yes |
+| `client/` → `@lilith/qr-device-login-client` | Server-side SDK for consuming apps (mTLS via undici) | Yes |
+| `react/` → `@lilith/qr-device-login-react` | `` + `useDeviceLogin` hook | Yes |
+
+## Consumer integration (3 thin proxy routes)
+
+```ts
+// backend: 3 thin proxy routes
+import { createQrLoginClient } from '@lilith/qr-device-login-client';
+
+const qr = createQrLoginClient({
+ baseUrl: process.env.QR_AUTH_BASE_URL!,
+ cert: readFileSync(process.env.QR_AUTH_CERT!),
+ key: readFileSync(process.env.QR_AUTH_KEY!),
+ ca: readFileSync(process.env.QR_AUTH_CA!),
+});
+
+// POST /api/device-link/start (authenticated)
+const session = await qr.createSession({
+ callbackUrl: `${PUBLIC_BASE}/auth/device-link/callback`,
+});
+
+// GET /api/device-link/poll/:id (authenticated)
+const status = await qr.pollSession(id);
+
+// GET /auth/device-link/callback?id=&code= (public)
+const { metadata } = await qr.exchangeCode(id, code);
+// → createToken() + setSessionCookie() → 302 '/'
+```
+
+```tsx
+// frontend
+import { DeviceLoginQR } from '@lilith/qr-device-login-react';
+
+ location.reload()}
+/>
+```
+
+## Deploy
+
+See `deploy/` — systemd unit + nginx site config for `qr-auth.lilith.live` on
+vps-0. Consumer certs live under `deploy/pki/` (private keys gitignored).
diff --git a/qr-device-login/app.manifest.yaml b/qr-device-login/app.manifest.yaml
new file mode 100644
index 0000000..ec9f074
--- /dev/null
+++ b/qr-device-login/app.manifest.yaml
@@ -0,0 +1,22 @@
+name: qr-device-login
+description: |
+ Reusable QR-based cross-device session-transfer service.
+ Consumers wrap via published SDK packages; session issuance stays with
+ each consuming app. mTLS-authenticated admin API + public scan landing.
+category: auth
+host: vps-0
+services:
+ - name: qr-device-login
+ kind: systemd
+ unit: qr-device-login.service
+ workdir: /opt/qr-device-login
+ ports:
+ - { port: 8787, role: loopback, description: "Bun service, fronted by nginx" }
+nginx:
+ sites:
+ - qr-auth.lilith.live
+packages:
+ publish:
+ - shared # @lilith/qr-device-login-protocol
+ - client # @lilith/qr-device-login-client
+ - react # @lilith/qr-device-login-react
diff --git a/qr-device-login/tsconfig.base.json b/qr-device-login/tsconfig.base.json
new file mode 100644
index 0000000..38c1e7c
--- /dev/null
+++ b/qr-device-login/tsconfig.base.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022"],
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "exactOptionalPropertyTypes": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true
+ },
+ "exclude": ["node_modules", "dist", "build"]
+}