{"slug": "add-phone-verification-to-an-express-app-with-claude-code-with-a-real-otp-behind", "title": "Add phone verification to an Express app with Claude Code, with a real OTP behind it", "summary": "A developer at MyOTP.App demonstrated wiring the company's OTP API into an Express app via a Claude Code MCP server, letting the model send a real SMS code and inspect the response before generating the verification routes. The resulting code keys verification on the API's message_id rather than the phone number and keeps the API key server-side, with the channel swappable to WhatsApp or Telegram. The author notes the pattern targets plain Express or Fastify apps, since Supabase and Better Auth already offer SMS hooks.", "body_md": "Most apps that need phone verification rebuild the same five things: a form, a send, an expiry, a retry rule and a verify step. It's not interesting code but it's load bearing. Here's what happens when Claude Code has a real OTP API attached while it writes that code. I work at MyOTP.App, the API in question.\n\nOne line, once:\n\n```\nclaude mcp add myotp -e MYOTP_API_KEY=your-key -- npx -y @myotp/mcp\n```\n\nThe key comes from myotp.app/sign-up, 15 trial credits, no card. Allowlist your IP on the key, or `*` while developing.\n\nAdd phone verification to my Express app. The user enters a phone number, gets a 6 digit code by SMS, types it in, and on success is marked verified. Use the MyOTP REST API from the server, never from the browser.\n\n``` js\n// routes/verify.js\nconst express = require(\"express\");\nconst router = express.Router();\nconst pending = new Map(); // phone -> message_id\n\nconst API = \"https://api.myotp.app\";\nconst headers = {\n  \"Content-Type\": \"application/json\",\n  \"X-API-Key\": process.env.MYOTP_API_KEY,\n};\n\n// MyOTP wants digits only, country code first, no plus, no leading zero.\nconst clean = (p) => (p || \"\").replace(/\\D/g, \"\").replace(/^0+/, \"\");\n\nrouter.post(\"/send-code\", async (req, res) => {\n  const phone = clean(req.body.phone);\n  if (!/^[1-9][0-9]{6,14}$/.test(phone)) {\n    return res.status(400).json({ error: \"invalid phone\" });\n  }\n  const r = await fetch(`${API}/generate_otp`, {\n    method: \"POST\", headers,\n    body: JSON.stringify({ phone_number: phone, channel: \"sms\", otp_length: 6 }),\n  });\n  const data = await r.json();\n  if (!r.ok) return res.status(r.status).json({ error: data.error?.message });\n  pending.set(phone, data.message_id);\n  res.json({ status: \"sent\", expires_at: data.expires_at });\n});\n\nrouter.post(\"/verify-code\", async (req, res) => {\n  const phone = clean(req.body.phone);\n  const message_id = pending.get(phone);\n  if (!message_id) return res.status(400).json({ error: \"no code pending\" });\n  const r = await fetch(`${API}/verify_otp`, {\n    method: \"POST\", headers,\n    body: JSON.stringify({ phone_number: phone, message_id, otp: req.body.code }),\n  });\n  const data = await r.json();\n  if (data.status !== \"success\") {\n    return res.status(400).json({ error: data.reason }); // \"invalid\" | \"expired\" | \"not found\"\n  }\n  pending.delete(phone);\n  res.json({ status: \"verified\" });\n});\n\nmodule.exports = router;\n```\n\nPlus the form and two lines in `app.js`. About a minute from prompt to a code arriving on my phone.\n\nThree things in that code are the things people get wrong by hand.\n\nThe phone regex matches what the API enforces. `phone_number` must be digits only, 7 to 15 of them, no leading zero. The tool description carries that rule, so the model didn't guess.\n\nVerification is keyed on `message_id`, not on the phone. Two codes in flight to the same number can't be confused.\n\nThe key never reaches the browser. The client posts a phone and later a code, nothing else.\n\nThe interesting part is that it tested the flow before writing it. With the MCP server attached, \"send a code to my number\" is a tool call. It sent one, read the response shape, then wrote code against what it had seen instead of what it remembered.\n\nChange `channel` to `whatsapp` or `telegram` and nothing else moves. Worth doing where SMS is expensive or slow, which is most of South Asia, Africa and Latin America.\n\nIf your framework already owns phone auth, use its hook instead. Supabase has the Send SMS Hook, Better Auth has a `sendOTP` callback, and both take a MyOTP adapter without you writing the routes above. This pattern is for the plain Express or Fastify app that has nothing yet.\n\nDisclosure again: I work at MyOTP.App. Prices and limits are on the site. If the code above breaks for you, tell me in the comments and I'll fix the post.", "url": "https://wpnews.pro/news/add-phone-verification-to-an-express-app-with-claude-code-with-a-real-otp-behind", "canonical_source": "https://dev.to/rayas/add-phone-verification-to-an-express-app-with-claude-code-with-a-real-otp-behind-it-4k81", "published_at": "2026-09-13 13:09:11+00:00", "updated_at": "2026-09-13 13:39:51.931771+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-tools", "ai-products"], "entities": ["MyOTP.App", "Claude Code", "Express", "Fastify", "Supabase", "Better Auth"], "alternates": {"html": "https://wpnews.pro/news/add-phone-verification-to-an-express-app-with-claude-code-with-a-real-otp-behind", "markdown": "https://wpnews.pro/news/add-phone-verification-to-an-express-app-with-claude-code-with-a-real-otp-behind.md", "text": "https://wpnews.pro/news/add-phone-verification-to-an-express-app-with-claude-code-with-a-real-otp-behind.txt", "jsonld": "https://wpnews.pro/news/add-phone-verification-to-an-express-app-with-claude-code-with-a-real-otp-behind.jsonld"}}