Password reset and email verification

When someone using your app forgets their password, your app asks JSONPad for a password reset token and sends it to them, usually as a link in an email. The link opens a page in your app where they choose a new password. Verifying an email address works the same way.

JSONPad never sends email to your users. The email comes from your app, from your own domain and with your own branding, so your users aren't surprised by a message from a service they've never heard of, and the links point to your own pages. You can send it with any email service, e.g. Resend, Postmark or SendGrid.

How it works

  1. Someone enters their email address on your app's "forgot password" page.
  2. Your app requests a reset token for that address.
  3. Your app emails them a link containing the token, e.g. https://example.com/reset-password?token=....
  4. On the page the link opens, your app sends the token and the new password to JSONPad.
  5. The identity is logged out everywhere, and logs in again with its new password.

Tokens can only be used once, and expire after 1 hour by default (you can change this for each identity group). Tokens are URL-safe, so they can go straight into a link.

Token delivery

A reset token lets whoever holds it set a new password for the identity. So the important question is how the token gets from JSONPad to your app without anyone else seeing it. Each identity group chooses one of two ways, in the dashboard:

  • Returned to the caller (the default). The request returns the token. This is simple, but the request must be made from a server (or a serverless function), using an API token that never reaches the browser. If you put an API token that can request reset tokens in your app's front end, anyone could use it to take over any account.
  • Sent to a webhook. JSONPad sends the token to a URL you choose, and the request only says { "delivery": "webhook" }. Because the request never returns a token, your app can make it straight from the browser. You still need something to receive the webhook and send the email: a small serverless function, or a no-code tool like Zapier, Make or n8n.

Requesting a reset token from a server

With the default delivery, make the request from your server. Create an API token for your server with the reset-password permission for your identity group, and keep it in an environment variable. Here's a complete "forgot password" endpoint:

Node (Express)
123456789101112131415161718192021222324252627282930313233343536373839404142434445import express from 'express'; const app = express(); app.use(express.json()); app.post('/forgot-password', async (req, res) => { // Ask JSONPad for a reset token (this must never happen in a browser) const response = await fetch('https://api.jsonpad.io/identities/password-reset', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-token': process.env.JSONPAD_SERVER_TOKEN!, }, body: JSON.stringify({ group: 'players', email: req.body.email, }), }); const { resetToken, identity } = await response.json(); // Only send an email if an identity was found if (resetToken && identity.email) { await fetch('https://api.resend.com/emails', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.RESEND_API_KEY}`, }, body: JSON.stringify({ from: 'My Game <accounts@example.com>', to: identity.email, subject: 'Reset your password', html: `<p>Hi ${identity.displayName ?? identity.name},</p> <p><a href="https://example.com/reset-password?token=${resetToken}"> Choose a new password</a>. This link expires in 1 hour.</p>`, }), }); } // Always respond the same way, so the form can't be used to find out // which email addresses have accounts res.json({ message: "If there's an account for that address, we've sent an email." }); }); app.listen(3000);

The response includes the identity, so you know where to send it:

{
resetToken: "mprJNQxDHeU1eUgCZaoAsnUtf6Z9Q6EZu80RpsW_cHM"
expiresAt: "2026-09-17T14:03:13.000Z"
identity: {
id: "59b9f5be-06ec-4e5d-8b4c-ab48b0e9bdc0"
name: "alice"
displayName: "Alice"
email: "alice@example.com"
emailVerified: false
}
}

If no identity matches, the token and identity are null. Your "forgot password" page should show the same message either way, so it can't be used to find out who has an account.

JSONPad refuses these requests if they come from a browser (with an Origin header), to catch the most likely mistake. That's a safety net, not protection: keep the token on your server.

The reset password page

The page your link opens runs in the browser, and uses your app's usual API token. That token needs the authenticate permission for the group (which it already has if people can log in).

JS/TS (SDK)
12345678910111213141516171819import JSONPad from '@basementuniverse/jsonpad-sdk'; const jsonpad = new JSONPad('<YOUR TOKEN>'); // e.g. https://example.com/reset-password?token=... const resetToken = new URLSearchParams(location.search).get('token')!; try { await jsonpad.confirmIdentityPasswordReset({ resetToken, password: newPasswordInput.value, }); // The identity has been logged out everywhere; ask it to log in again location.href = '/login?reset=1'; } catch (error) { // IDENTITY_RESET_TOKEN_INVALID: the link was used already, or has expired showMessage('This link has expired. Please request a new one.'); }

If the link has been used before, has expired, or a newer link has been requested since, the request fails with IDENTITY_RESET_TOKEN_INVALID.

Webhook delivery

If your app has no server, set the identity group's token delivery to Send to webhook and enter your webhook's URL. The dashboard shows the group's signing secret, and has a Send test button to check your webhook is reachable.

Your app can then request tokens straight from the browser. The public API token needs the reset-password permission for the group:

JS/TS (SDK)
1234567891011import JSONPad from '@basementuniverse/jsonpad-sdk'; const jsonpad = new JSONPad('<YOUR TOKEN>'); // Returns { delivery: 'webhook' }, whether or not the identity exists await jsonpad.requestIdentityPasswordReset({ group: 'players', email: emailInput.value, }); showMessage("If there's an account for that address, we've sent an email.");

Receiving webhooks

JSONPad sends a POST request with a JSON body to your webhook:

{
id: "2f1c0c2e-6f7e-4a53-9b2a-5d3a3c8f1e21"
type: "identity.password-reset-requested"
createdAt: "2026-09-17T13:03:13.000Z"
data: {
group: "players"
identity: {
id: "59b9f5be-06ec-4e5d-8b4c-ab48b0e9bdc0"
name: "alice"
displayName: "Alice"
email: "alice@example.com"
emailVerified: false
}
resetToken: "mprJNQxDHeU1eUgCZaoAsnUtf6Z9Q6EZu80RpsW_cHM"
expiresAt: "2026-09-17T14:03:13.000Z"
}
}
  • typestringidentity.password-reset-requested (with resetToken), identity.email-verification-requested (with verificationToken), or ping from the dashboard's Send test button.
  • x-jsonpad-signatureheadert=<timestamp>,v1=<signature>, where the signature is the hex HMAC-SHA256 of <timestamp>.<body> using the group's signing secret. Always check it, and that the timestamp is recent, before trusting the request.
  • x-jsonpad-eventheaderThe same as type.
  • x-jsonpad-deliveryheaderThe same as id. It stays the same if a delivery is retried, so you can ignore duplicates.
Node (Express)
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455import crypto from 'crypto'; import express from 'express'; const app = express(); // Keep the raw body: the signature is computed over the exact bytes sent app.post( '/webhooks/jsonpad', express.raw({ type: 'application/json' }), async (req, res) => { const body = req.body.toString('utf8'); const signature = req.header('x-jsonpad-signature') ?? ''; const [, timestamp, v1] = signature.match(/^t=(\d+),v1=([0-9a-f]+)$/) ?? []; const expected = crypto .createHmac('sha256', process.env.JSONPAD_WEBHOOK_SECRET!) .update(`${timestamp}.${body}`) .digest('hex'); const valid = !!v1 && v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected)) && Math.abs(Date.now() / 1000 - Number(timestamp)) < 5 * 60; if (!valid) { return res.status(401).end(); } const event = JSON.parse(body); if (event.type === 'identity.password-reset-requested') { const { identity, resetToken } = event.data; await sendEmail( identity.email, 'Reset your password', `https://example.com/reset-password?token=${resetToken}` ); } if (event.type === 'identity.email-verification-requested') { const { identity, verificationToken } = event.data; await sendEmail( identity.email, 'Verify your email address', `https://example.com/verify-email?token=${verificationToken}` ); } // Respond quickly with a 2xx, or JSONPad will retry res.status(204).end(); } ); app.listen(3000);

Respond with any 2xx status within 10 seconds. If your webhook can't be reached, or responds with 408, 429 or a 5xx status, JSONPad tries twice more over the next 12 seconds. Every delivery, successful or not, is recorded (without the token) on the dashboard's Events page.

Email verification

To check that an identity owns its email address, request an email verification token (with the verify-email permission), send it as a link, and confirm it on the page the link opens. Token delivery works exactly as it does for password resets.

Server
123456789101112131415161718192021// After an identity registers, request a verification token and email it const response = await fetch('https://api.jsonpad.io/identities/email-verification', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-token': process.env.JSONPAD_SERVER_TOKEN!, }, body: JSON.stringify({ group: 'players', identityId: identity.id, }), }); const { verificationToken } = await response.json(); if (verificationToken) { await sendEmail( identity.email, 'Verify your email address', `https://example.com/verify-email?token=${verificationToken}` ); }

An identity's emailVerified is false again whenever its email address changes, and tokens sent to the old address stop working. Resetting a password also verifies the email address, since the reset link reached it.

Security checklist

  • Never give an API token with the reset-password, verify-email or * permission to a browser, unless the group uses webhook delivery.
  • Show the same message on your "forgot password" page whether or not an account exists.
  • Serve your reset and verification pages over https.
  • Verify webhook signatures and timestamps.
  • Keep token lifetimes short. Each identity can only request a token once a minute, which limits how many emails someone can trigger.
2026-09-17