#256 Set Up Neon Auth Email Webhooks With Resend#261
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
b-at-neu
left a comment
There was a problem hiding this comment.
Code Review — Cycle 1 · needs revision
4 open — 1 🟠 Medium, 2 🟡 Low, 2 ⚪ Nit (see inline)
4c00a14 to
377603c
Compare
Revision — Cycle 1fixed R1-M1, R1-L1, R1-L2, R1-N1, R1-N2 · 377603c |
b-at-neu
left a comment
There was a problem hiding this comment.
Code Review — Cycle 2 · approved
0 open — all prior findings resolved, no regressions
b-at-neu
left a comment
There was a problem hiding this comment.
Code Review — Env Var Rename
Cycle 3 — 1 Low finding
R3-L1 · Low — NEON_AUTH_WEBHOOK_SECRET is a misleading name; rename to SKIP_WEBHOOK_VERIFICATION
The webhook uses Ed25519 public-key verification via Neon's JWKS endpoint — no secret is involved at runtime. This variable only controls the dev bypass sentinel. The current name implies a shared HMAC secret, which is incorrect and confusing.
Required changes in lib/email/verify-webhook.ts:
- Rename all references from
NEON_AUTH_WEBHOOK_SECRETtoSKIP_WEBHOOK_VERIFICATION. - Remove the
DEV_SENTINEL = 'dev'constant — it no longer applies. Change the check to a simple truthy check:if (process.env.SKIP_WEBHOOK_VERIFICATION) { if (process.env.NODE_ENV === 'production') throw new Error('SKIP_WEBHOOK_VERIFICATION cannot be set in production'); return { valid: true }; }
- Update the inline comment above the check to reflect the new variable name and truthy semantics.
Also update .env.example (if it references the old name): change to SKIP_WEBHOOK_VERIFICATION=true with a comment explaining it bypasses Ed25519 signature verification in local dev.
377603c to
8410b43
Compare
Revision — Cycle 3fixed R3-L1 · 8410b43 |
Code ReviewCycle 9 — 1 Medium finding R9-M1 · Medium —
|
Revision — Cycle 8fixed R8-M1 · 8caf7c6 |
b-at-neu
left a comment
There was a problem hiding this comment.
Code Review — Cycle 9 · approved
0 open — R8-M1 resolved (jose added as direct dependency); no regressions; all CI checks pass
Code ReviewCycle 10 — 1 Critical finding R10-C1 · Critical — Wrong logo, generic design; branded redesign requiredThree changes needed in 1. Replace the logo — the current At the top of the file, generate the data URI at module init by reading import fs from 'fs';
import path from 'path';
const LOGO_DATA_URI = `data:image/svg+xml;base64,${Buffer.from(
fs.readFileSync(path.join(process.cwd(), 'public/logo-light.svg'))
).toString('base64')}`;Also: delete 2. Branded header design — replace the current layout in Replace the entire <tr>
<td>
<table width="100%" cellpadding="0" cellspacing="0">
<!-- Branded red header -->
<tr>
<td bgcolor="#D41B2C" style="background-color:#D41B2C;padding:20px 28px;border-radius:10px 10px 0 0;">
<table cellpadding="0" cellspacing="0">
<tr>
<td style="vertical-align:middle;padding-right:10px;">
<img src="${LOGO_DATA_URI}" width="34" height="34" alt="" style="display:block;border-radius:6px;" />
</td>
<td style="vertical-align:middle;">
<span style="font-size:17px;font-weight:700;color:#ffffff;letter-spacing:-0.01em;">Aplio</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- White card body -->
<tr>
<td bgcolor="#ffffff" style="background-color:#ffffff;padding:32px;border:1px solid #e4e4e7;border-top:none;border-radius:0 0 10px 10px;">
${content}
</td>
</tr>
</table>
</td>
</tr>Remove the old separate logo/wordmark header row above the card (the 3. Update copy — make it specific to Aplio, not generic email-client boilerplate. In
In
Footer text (in Do not change |
Revision — Cycle 10fixed R10-C1 · 8986ea1 |
b-at-neu
left a comment
There was a problem hiding this comment.
Code Review — Cycle 10 · approved
0 open — all CI checks pass; email template redesign with embedded SVG logo and branded red accents is correct; jose direct dependency in place; signature verification logic unchanged and previously confirmed correct; no Critical or Medium findings
Code ReviewCycle 11 — 1 Critical finding R11-C1 · Critical —
|
Revision — Cycle 10fixed: inline SVG logo (remove fs/path imports, embed as base64 string) · 28d64d0 · rebase: lib/types.ts (accepted both sides — PositionApplicationStats from dev + Neon webhook types from PR commit 1, then accepted deletion of Neon webhook types from PR commit 2 per R1-N2) |
b-at-neu
left a comment
There was a problem hiding this comment.
Code Review — Cycle 11 · approved
0 Critical, 0 Medium — 1 🟡 Low (non-blocking at cycle 11), 1 ⚪ Nit (see inline / body)
Off-diff note (R11-L2): lib/types.ts was never updated with NeonAuthWebhookOtpEvent, NeonAuthWebhookMagicLinkEvent, and NeonAuthWebhookEvent despite both the plan and the PR description stating they'd be added as shared exports for future consumers. The webhook works correctly without them (route uses local zod schemas), but the promised shared types are absent. Follow-up if consumers ever need them. https://github.com/SGAOperations/aplio/blob/28d64d020732069029f332cfd37ea2292c7d869a/lib/types.ts
| event_type: z.literal('send.magic_link'), | ||
| user: userSchema, | ||
| event_data: z.object({ | ||
| link_url: z.string().url(), |
There was a problem hiding this comment.
R11-L1 🟡 Low — z.string().url() accepts any scheme (including javascript:, data:). Neon always sends https:// magic-link URLs, and EdDSA signature verification sharply limits the attack surface, but defence-in-depth would add a scheme constraint: .refine(u => u.startsWith('https://'), 'link_url must be https'). Non-blocking at cycle 11.
and delivers branded emails through Resend rather than Neon's default stackframe.co sender. Includes Ed25519 signature verification with a dev sentinel to skip verification locally. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…format Rewrites verifyWebhookSignature to: - Parse X-Neon-Signature as a detached JWS (headerB64..signatureB64) - Accept and validate X-Neon-Timestamp header (5-min replay window) - Build signing input per Neon spec: headerB64 + "." + base64url(ts + "." + base64url(body)) - Use Node crypto.createPublicKey + crypto.verify instead of crypto.subtle Updates route.ts to read X-Neon-Timestamp and pass it to verify function. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Node's crypto.createPublicKey delegates Ed25519 JWK handling to Web Crypto internally in the Next.js runtime, throwing InvalidCharacterError. Switch to jose (already a direct dep) which handles Ed25519 import and JWS verification correctly across all environments. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Neon uses event_type (not type) as the discriminator, user.email for
the recipient, and event_data.{otp_code,link_url} for event fields.
Also removes debug logging.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Format from address as "Aplio <addr>" in sendEmail so inboxes show the display name. Add public/logo-256.png for use in email templates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Light background, red (#D41B2C) top border accent and OTP code border, red CTA button for magic link. Logo embedded as base64 PNG data URI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
jose is directly imported in lib/email/verify-webhook.ts; declaring it explicitly ensures the semver contract is lockfile-tracked for this code rather than relying on a transitive dependency of the Neon Auth SDK. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove fs/path imports; embed logo SVG directly as a string so the email template has no filesystem dependency at send time. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the inline base64 SVG LOGO_DATA_URI with a LOGO_URL that points to /logo-512.svg served by Next.js from public/. VERCEL_URL is set automatically in Vercel deployments; falls back to localhost:3000 for local dev. Adds public/logo-512.svg as the asset to be served. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Revision — Cycle 11human-directed fix: replace |
b-at-neu
left a comment
There was a problem hiding this comment.
Code Review — Cycle 12 · approved
0 blocking — 1 🟡 Low, 1 ⚪ Nit (see inline; cycle bar is Critical/Medium only at cycle 3+)
| kidHeader: string | null, | ||
| timestampHeader: string | null, | ||
| ): Promise<VerifyWebhookResult> { | ||
| if (process.env.SKIP_WEBHOOK_VERIFICATION) { |
There was a problem hiding this comment.
R12-L1 🟡 Low — if (process.env.SKIP_WEBHOOK_VERIFICATION) is a truthy check, so any non-empty string (including "false" or "0") bypasses verification. Matches the documented "true" value, but setting SKIP_WEBHOOK_VERIFICATION=false in .env.local would still skip. The production guard (throw below) prevents real damage, but the behavior is surprising. Fix: if (process.env.SKIP_WEBHOOK_VERIFICATION === 'true').
| @@ -0,0 +1,124 @@ | |||
| import { z } from 'zod'; | |||
There was a problem hiding this comment.
R12-N1 ⚪ Nit — The plan and PR description both list lib/types.ts as a change adding NeonAuthWebhookOtpEvent, NeonAuthWebhookMagicLinkEvent, and NeonAuthWebhookEvent discriminated union types, but lib/types.ts is not in the diff and its file size is identical to dev. The route works correctly with its local zod schemas — no functional gap — but the PR description's Changes section inaccurately describes what was shipped. Either add the types or update the description.
Closes #256
Summary
POST /api/auth/webhookroute that intercepts Neon Authsend.otpandsend.magic_linkevents and delivers branded transactional emails through Resend instead of Neon's defaultstackframe.cosender.devsentinel to skip verification in local development.Changes
app/api/auth/webhook/route.ts— new POST handler: reads raw body once, verifies Neon signature, zod-parses the discriminated-union payload, dispatches to the appropriate template, sends via Resend, returns200/400/500. The staticwebhooksegment takes precedence over the[...path]catch-all; existing auth routes are unaffected.lib/email/resend.ts—server-onlyResend client singleton +sendEmail()wrapper. Throws on missing env vars or Resend API errors so the webhook handler can return500for Neon to retry.lib/email/templates.ts—otpEmail()andmagicLinkEmail()returning{ subject, html, text }. Uses inline styles with hex values matching Aplio design tokens (Tailwind classes are ignored by email clients — noted in a comment).lib/email/verify-webhook.ts— EdDSA (Ed25519) signature verification via native Web Crypto (Node 20+). Fetches JWKS once per process and caches in-module; busts cache onkidmiss for key rotation. Returns{ valid: true }whenNEON_AUTH_WEBHOOK_SECRET=dev.lib/types.ts— addsNeonAuthWebhookOtpEvent,NeonAuthWebhookMagicLinkEvent, andNeonAuthWebhookEventdiscriminated union types..env.example— addsRESEND_API_KEY,RESEND_FROM_EMAIL,NEON_AUTH_WEBHOOK_SECRET(with sentinel note).README.md— extends the env-vars table with the three new variables and sentinel note.package.json/package-lock.json—resenddependency added vianpm install resend.Testing plan
RESEND_API_KEY,RESEND_FROM_EMAIL, andNEON_AUTH_WEBHOOK_SECRET=devin.env.local; runnpm run dev.send.otppayload tohttp://localhost:3000/api/auth/webhook(sentinel skips signature) — expect200response and a branded OTP email at the test address showing the code and expiry.send.magic_linkpayload — expect200response and a magic-link email with a working CTA button linking to the provided URL.type(e.g.{"type":"unknown","data":{}}) — expect400, no email sent.400.NEON_AUTH_WEBHOOK_SECRETset to a real (non-dev) value but withoutX-Neon-Signature/X-Neon-Signature-Kidheaders — expect400.400.[...path]auth routes (login, OTP callback, magic-link callback) still function correctly — the staticwebhooksegment must not shadow them.https://<preview-url>/api/auth/webhook; trigger a real OTP sign-in and a real magic-link sign-in; confirm both branded emails arrive fromRESEND_FROM_EMAIL(notstackframe.co) and that sign-in completes successfully.Automated checks
npm run prettier:check— passesnpm run eslint:check— passesnpm run tsc:check— passes (Ed25519Uint8Array<ArrayBuffer>constraint resolved)Notes
josedependency added. Node 20+ / Edge Runtime both support Ed25519 natively. Thejosefallback described in the issue was not needed.RESEND_FROM_EMAILuses an unverified domain, Resend will reject sends silently into spam or return an API error. Domain verification must be completed in the Resend console before going live. This is called out as a deploy prerequisite.2xx, so a transient Resend failure returning500may cause a duplicate send. This is acceptable for short-lived OTP/magic-link emails and is noted in the route handler comment.lib/types.tsare not imported by the route (the route uses local zod schemas); they are exported for any future server-side consumers that need to work with the typed payload shape.