Skip to content

#256 Set Up Neon Auth Email Webhooks With Resend#261

Merged
b-at-neu merged 18 commits into
devfrom
256-set-up-neon-auth-email-webhooks-with-resend
Jun 29, 2026
Merged

#256 Set Up Neon Auth Email Webhooks With Resend#261
b-at-neu merged 18 commits into
devfrom
256-set-up-neon-auth-email-webhooks-with-resend

Conversation

@b-at-neu

Copy link
Copy Markdown
Collaborator

Closes #256

Summary

  • Adds a POST /api/auth/webhook route that intercepts Neon Auth send.otp and send.magic_link events and delivers branded transactional emails through Resend instead of Neon's default stackframe.co sender.
  • Implements EdDSA (Ed25519) signature verification against the Neon JWKS endpoint with an in-module cache and a dev sentinel to skip verification in local development.
  • Provides on-brand inline-styled email templates for both event types with plain-text fallbacks.

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, returns 200/400/500. The static webhook segment takes precedence over the [...path] catch-all; existing auth routes are unaffected.
  • lib/email/resend.tsserver-only Resend client singleton + sendEmail() wrapper. Throws on missing env vars or Resend API errors so the webhook handler can return 500 for Neon to retry.
  • lib/email/templates.tsotpEmail() and magicLinkEmail() 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 on kid miss for key rotation. Returns { valid: true } when NEON_AUTH_WEBHOOK_SECRET=dev.
  • lib/types.ts — adds NeonAuthWebhookOtpEvent, NeonAuthWebhookMagicLinkEvent, and NeonAuthWebhookEvent discriminated union types.
  • .env.example — adds RESEND_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.jsonresend dependency added via npm install resend.

Testing plan

  • Set RESEND_API_KEY, RESEND_FROM_EMAIL, and NEON_AUTH_WEBHOOK_SECRET=dev in .env.local; run npm run dev.
  • POST a valid send.otp payload to http://localhost:3000/api/auth/webhook (sentinel skips signature) — expect 200 response and a branded OTP email at the test address showing the code and expiry.
  • POST a valid send.magic_link payload — expect 200 response and a magic-link email with a working CTA button linking to the provided URL.
  • POST an unknown type (e.g. {"type":"unknown","data":{}}) — expect 400, no email sent.
  • POST a malformed body (not valid JSON) — expect 400.
  • POST a valid payload with NEON_AUTH_WEBHOOK_SECRET set to a real (non-dev) value but without X-Neon-Signature / X-Neon-Signature-Kid headers — expect 400.
  • POST a valid payload with a tampered body (signature mismatch) — expect 400.
  • Confirm existing [...path] auth routes (login, OTP callback, magic-link callback) still function correctly — the static webhook segment must not shadow them.
  • In a deployed preview: register the webhook in the Neon console pointing to https://<preview-url>/api/auth/webhook; trigger a real OTP sign-in and a real magic-link sign-in; confirm both branded emails arrive from RESEND_FROM_EMAIL (not stackframe.co) and that sign-in completes successfully.
  • Verify the sender domain is verified in Resend for the deploy environment — an unverified domain will silently fail (see Notes below).

Automated checks

  • npm run prettier:check — passes
  • npm run eslint:check — passes
  • npm run tsc:check — passes (Ed25519 Uint8Array<ArrayBuffer> constraint resolved)

Notes

  • Native Web Crypto used — no jose dependency added. Node 20+ / Edge Runtime both support Ed25519 natively. The jose fallback described in the issue was not needed.
  • Resend sender domain verification is required for production — if RESEND_FROM_EMAIL uses 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.
  • Idempotency — Neon retries on non-2xx, so a transient Resend failure returning 500 may cause a duplicate send. This is acceptable for short-lived OTP/magic-link emails and is noted in the route handler comment.
  • The webhook types in lib/types.ts are 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.

@b-at-neu b-at-neu self-assigned this Jun 27, 2026
@vercel

vercel Bot commented Jun 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
aplio Ready Ready Preview, Comment Jun 29, 2026 9:38am

@b-at-neu b-at-neu added ready for review PR ready for review agent reviewing Review agent working (in-flight) and removed ready for review PR ready for review agent labels Jun 27, 2026

@b-at-neu b-at-neu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — Cycle 1 · needs revision

4 open — 1 🟠 Medium, 2 🟡 Low, 2 ⚪ Nit (see inline)

Comment thread lib/email/verify-webhook.ts Outdated
Comment thread app/api/auth/webhook/route.ts Outdated
Comment thread lib/email/templates.ts Outdated
Comment thread lib/email/templates.ts
Comment thread lib/types.ts Outdated
@b-at-neu b-at-neu added needs revision Review found issues that need fixing revising Revise agent working (in-flight) and removed reviewing Review agent working (in-flight) needs revision Review found issues that need fixing labels Jun 27, 2026
@b-at-neu
b-at-neu force-pushed the 256-set-up-neon-auth-email-webhooks-with-resend branch from 4c00a14 to 377603c Compare June 27, 2026 09:52
@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Revision — Cycle 1

fixed R1-M1, R1-L1, R1-L2, R1-N1, R1-N2 · 377603c

@b-at-neu b-at-neu added ready for review PR ready for review agent reviewing Review agent working (in-flight) and removed revising Revise agent working (in-flight) ready for review PR ready for review agent labels Jun 27, 2026

@b-at-neu b-at-neu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — Cycle 2 · approved

0 open — all prior findings resolved, no regressions

@b-at-neu b-at-neu added approved Review passed, ready to merge and removed reviewing Review agent working (in-flight) labels Jun 27, 2026

@b-at-neu b-at-neu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Rename all references from NEON_AUTH_WEBHOOK_SECRET to SKIP_WEBHOOK_VERIFICATION.
  2. 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 };
    }
  3. 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.

@b-at-neu b-at-neu added needs revision Review found issues that need fixing revising Revise agent working (in-flight) and removed approved Review passed, ready to merge needs revision Review found issues that need fixing labels Jun 27, 2026
@b-at-neu
b-at-neu force-pushed the 256-set-up-neon-auth-email-webhooks-with-resend branch from 377603c to 8410b43 Compare June 27, 2026 10:27
@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Revision — Cycle 3

fixed R3-L1 · 8410b43

@b-at-neu b-at-neu added ready for review PR ready for review agent and removed revising Revise agent working (in-flight) labels Jun 27, 2026
@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Code Review

Cycle 9 — 1 Medium finding


R9-M1 · Medium — jose must be a declared direct dependency

lib/email/verify-webhook.ts imports compactVerify and importJWK from jose, but jose is not listed in package.json — it only resolves as a transitive dependency of the Neon Auth SDK. If that SDK ever drops or upgrades jose to a breaking major, the import silently breaks at runtime.

Fix: run npm install jose so it appears in package.json dependencies. No code changes needed.

@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Revision — Cycle 8

fixed R8-M1 · 8caf7c6

@b-at-neu b-at-neu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — Cycle 9 · approved

0 open — R8-M1 resolved (jose added as direct dependency); no regressions; all CI checks pass

@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Code Review

Cycle 10 — 1 Critical finding


R10-C1 · Critical — Wrong logo, generic design; branded redesign required

Three changes needed in lib/email/templates.ts:


1. Replace the logo — the current LOGO_DATA_URI is a wrong PNG. Replace it with the actual app logo.

At the top of the file, generate the data URI at module init by reading public/logo-light.svg:

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 public/logo-256.png — it was added by a prior commit and should not exist in this repo.


2. Branded header design — replace the current layout in emailLayout().

Replace the entire <table> card block (the one with the red-stripe nested table and white content <td>) with this structure — a red header band containing the logo + wordmark, immediately followed by the white body:

<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 <td style="padding-bottom:24px;" align="center"> row with the img + Aplio span). The logo now lives inside the red header band.


3. Update copy — make it specific to Aplio, not generic email-client boilerplate.

In otpEmail():

  • Subject: 'Your Aplio access code'
  • <h1> text: 'Your access code'
  • <p> below heading: 'Enter this code to sign in to your Aplio account. It is single-use and will expire shortly.'
  • Plain-text first line: 'Your Aplio access code'

In magicLinkEmail():

  • Subject: 'Sign in to Aplio' (keep)
  • <h1> text: 'Your sign-in link'
  • <p> below heading: 'Use the button below to sign in to your Aplio account — no password needed.'

Footer text (in emailLayout()): 'You're receiving this because a sign-in was requested for your Aplio account.<br />Didn't request this? You can safely ignore it.'


Do not change verify-webhook.ts, resend.ts, or any route files.

@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Revision — Cycle 10

fixed R10-C1 · 8986ea1

@b-at-neu b-at-neu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Code Review

Cycle 11 — 1 Critical finding


R11-C1 · Critical — fs.readFileSync for logo fails in production

lib/email/templates.ts uses fs.readFileSync(path.join(process.cwd(), 'public/logo-light.svg')) at module init to build the logo data URI. In production (Vercel), public/ is served via CDN and the file is not reliably accessible through Node.js fs — the image renders as a broken image box.

Fix: Remove the fs and path imports entirely. Inline the SVG content directly as a string constant:

const LOGO_DATA_URI =
  'data:image/svg+xml;base64,' +
  Buffer.from(
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"><rect width="512" height="512" rx="80" fill="#fbfbfb"/><path d="m93 156 100 100L93 356" stroke="#47191f" stroke-width="80" stroke-linecap="round" fill="none"/><path d="m206 156 100 100-100 100" stroke="#881924" stroke-width="80" stroke-linecap="round" fill="none"/><path d="m319 156 100 100-100 100" stroke="#d41b2c" stroke-width="80" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>'
  ).toString('base64');

Remove the import fs from 'fs' and import path from 'path' lines. No other changes needed.

@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Revision — Cycle 10

fixed: 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 b-at-neu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

b-at-neu and others added 18 commits June 29, 2026 11:31
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>
@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Revision — Cycle 11

human-directed fix: replace LOGO_DATA_URI base64 inline with hosted LOGO_URL (/logo-512.svg via VERCEL_URL) · add public/logo-512.svg · 2d92e6b

@b-at-neu b-at-neu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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';

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Review passed, ready to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Set Up Neon Auth Email Webhooks With Resend

1 participant