Guides

Embedded signing security: the right URL for the right person

Keep your signing API key server-side, give each user only their own embedded session, set allowed origins, and handle expiry without sending another document. A practical security checklist with tested Node.js code.

Akbar Ali · 14 October 2026

The iframe loaded. The signature box works. I can tick "embedded signing" off the list, right?

I'd check one more thing first: whose signing URL did I put in that iframe?

An embedded signing URL lets a person act on a particular recipient's document. It is not a harmless preview link. If my app gives the wrong person that URL, a neat-looking iframe is still a broken integration. This is the security checklist I use around PutMySign's signing API, separate from the React mounting guide and the Next.js walkthrough.

The code below was tested locally on Node.js 22. It tests access decisions and response filtering, not a real signing ceremony or a full production authentication system.

Trap 1: the API key went to the browser

The API key belongs on your server. Your browser calls your app; your app calls PutMySign with its bearer key. A client component, a public environment variable, or a frontend bundle is the wrong place for it.

A browser needs one short-lived signing URL. It doesn't need the API key, every recipient's email, or the whole send response.

Keep that distinction even when you're prototyping with a test key. Test keys skip email; that is useful for building, not a reason to publish a credential.

Trap 2: choosing the first session

With embed: true, the template-send response contains embed.sessions, one entry per recipient. That is convenient on your server. Returning the whole array to the client is convenient for the wrong reason.

Futurama Fry meme. Top caption: "Not sure if the right signer". Bottom caption: "Or just the first URL in the array".
sessions[0] is not an access-control policy.

I store the recipient ID from the send response against the correct user and deal in my own database. On a later signing request, my server authenticates the user, checks their access to that deal, then retrieves that stored recipient ID. I don't let the browser choose a recipient_id, email, document, or role and call that permission.

Here is a deliberately small helper for an app where only the assigned user can open this signing session:

// signing-access.js - Node.js 22+, server-side only
export function signingSessionForUser({ userId, assignment, document }) {
  // assignment comes from YOUR database, not req.body or a query parameter.
  if (!userId || !assignment || assignment.userId !== userId) {
    throw new Error("Signing session not available");
  }
  if (assignment.documentId !== document?.id) {
    throw new Error("Signing session not available");
  }
  const sessions = document.embed?.sessions;
  const matches = Array.isArray(sessions)
    ? sessions.filter((s) => s.recipient_id === assignment.recipientId)
    : [];
  if (matches.length !== 1) throw new Error("Signing session not available");

  const url = new URL(matches[0].url);
  if (url.origin !== "https://putmysign.com" ||
      !url.pathname.startsWith("/embed/sign/") ||
      url.username || url.password) {
    throw new Error("Unexpected signing URL");
  }
  return { url: url.href }; // no other signer's session or email leaves the server
}

userId must come from your verified server-side login session. assignment must be loaded through your app's authorized database path. document here is the embedded send response your server already received, not a body posted by the browser. You still need authentication, deal-level authorization, secure storage, and an HTTP route around this helper. It doesn't supply those for you.

The URL check is for the current PutMySign production origin; don't copy it unchanged for another provider. The important part is the stored user/document/recipient binding, not an email address a caller typed in.

When sending, map the returned recipient_id values to your verified users once. Then return only the current person's session to their browser. No sessions[0]. No full response dump. The multiple-signers guide covers template roles and signing order.

Trap 3: treating allowed origins as a login check

Before embedding, add your application's domains to the key's embed origins under Developers. A key with no origins cannot be embedded anywhere. PutMySign serves the embedded page with a frame-ancestors policy based on that list.

That controls which site can frame the signing page. It doesn't decide which signed-in user of your site should get Dana's session. Those are different checks, and I need both.

Use the origins you actually serve from, including the scheme and any development port. Keep production and development settings intentional. Don't diagnose a frame refusal by exposing the API key or returning more URLs to the browser.

OWASP's authorization guidance puts the app-side rule plainly: check permissions on every request. The fact that somebody can reach your signing route is not permission to sign that document.

Trap 4: keeping a signing URL forever

PutMySign embed URLs last 30 minutes. That is separate from the document's signing deadline, which is controlled by expiry_days. A signing document can still be open after an old embed URL stops working.

Create the document and its initial embedded sessions when the user reaches the signing step, not in a background job an hour before. Keep the document ID so you know which signing request already exists.

A reload, timeout, or expired iframe is not a reason to call the template-send endpoint again. That endpoint creates a document. If you repeat it blindly, you can create another agreement rather than resume the first one.

The current public API reference describes the 30-minute URLs but does not document a separate refresh-session endpoint. I'm not going to invent one in a code sample, or pretend GET /documents/:id returns fresh embed sessions. Confirm the supported way to resume an existing document before promising that flow in your UI.

For the same reason, a server-rendered page is not a deduplication mechanism. A second visit can run server code again. Save the document ID and reconcile creation in your application's durable state; moving a POST out of useEffect doesn't make repeated visits safe by itself.

Trap 5: leaking the URL through the boring parts

I treat the full URL as sensitive access data. I don't paste it into support tickets, analytics events, console logs, or a shared screenshot. A debug event can say "signing session failed" and carry an internal request ID without carrying the session token.

On my own route that returns the current user's session, I use Cache-Control: private, no-store. I keep it out of a shared CDN cache and avoid putting it in a query string on my own pages. That doesn't replace authorization; it reduces accidental redistribution after authorization.

OWASP's logging guidance lists access tokens and session identifiers among the data that should not be recorded directly. Your session-replay and analytics tools deserve the same review: check what they collect from the signing screen before enabling them there.

Don't assume a short expiry makes a leak harmless. Thirty minutes can still be long enough for someone to use the wrong session.

Trap 6: trusting the browser's "done"

The embedded SDK's onSigned callback means this person signed. It is useful for changing the screen. It is not my server's proof that the whole document is complete.

A user can call their own JavaScript, reload, close the tab, or miss a callback. I finish the business workflow from a verified document.completed webhook on my server. I use recipient.signed for per-person progress. With two signers, those are visibly different milestones.

The signed-webhook guide covers raw bytes, HMAC verification, timestamps, and duplicate deliveries. A callback from the iframe doesn't skip those checks. Nor does a redirect to a page called /success.

The check before I ship

  • The browser bundle contains no signing API key.
  • An authenticated user can obtain only their own stored recipient's session.
  • Another user's ID, recipient ID, or document ID does not unlock that session.
  • The production origin is allowed to frame it; a blank origin list is treated as a setup problem.
  • A reload doesn't blindly send another document.
  • Logs, analytics, caches, and screenshots don't collect the tokenized URL.
  • Completion comes from a verified server event, not a browser callback.

Start with a two-role template and a test key. Inspect the response on your server, deliberately try the wrong user's assignment, and make sure it fails before you mount anything. Then check the real origin and completion flow with your production setup.

Try PutMySign's embedded signing, or read the API reference. The iframe is the visible bit. The binding between your user, their recipient, and their document is the bit I don't leave to an array index.