Docs
Portals

Signed sign-in

Sign a visitor's email on your server and hand it to an embedded Portal, so a visitor who is already logged in to your site arrives signed in with no one-time code.

Signed sign-in

Rolling out

Signed sign-in is new. If you do not see the Signed sign-in card under a Portal's Advanced security settings yet, the rollout has not reached your organization.

By default a Portal identifies nobody. A visitor enters their email, receives a one-time code, and signs in. If the Portal is embedded behind your own customer login, that step is redundant: your site already knows who the visitor is. Signed sign-in lets your site say so.

Your page sets a small config object before the embed script loads, with the visitor's email and a signature your server computed:

<script>
  window.AIContextConfig = {
    context: { "email": "jane@example.com" },
    signature: "1bc2ffc49f525ba68e3dd41b5330f4bac15ed4a64210274f12f5a22b18654344",
    ts: 1757500000
  };
</script>
<script src="https://app.formwise.ai/embed.js" async></script>
<iframe src="https://app.formwise.ai/d/YOUR-PORTAL-KEY"></iframe>

The Portal presents that to FormWise when it loads. The visitor arrives signed in as jane@example.com, with their conversations, plans, and credits, and is never asked for a code. The session lasts four hours and starts again on every page load, as long as your page keeps signing the visitor in. The first time an email arrives this way the account is created, exactly as it would be at the one-time code step.

How it is secured

Anyone can put an email in a script tag, so the email alone proves nothing. Your server signs it with your organization's secret key using HMAC with SHA-256, and FormWise checks the signature with the same key. A visitor cannot forge a signature without the secret, and the secret never leaves your server.

The signature also covers a timestamp. A signed sign-in is accepted for ten minutes from its timestamp, so a copied signature is useless soon after it was made.

Only sign emails you have verified

Anyone who holds the secret can sign in as anyone. Sign the email of a visitor your site has already authenticated, never an address the visitor typed into a form on your page. If your signup flow does not verify email addresses, a visitor could sign up as someone else and reach that person's history.

Turning it on

Signed sign-in is off for every Portal until you choose it, so a leaked secret cannot sign anyone in where you did not intend.

  1. Open SettingsAPI in your workspace and find the Session context signing card. Click Generate secret if you have not already. The secret starts with fwsc_, is shown once, and goes straight into your server's configuration. Rotate secret replaces it; pages still signing with the old secret are refused from then on.
  2. Open the Portal's Access page, expand Advanced security settings, and turn on Allow signed sign-in on the Signed sign-in card.
  3. Make sure the page that embeds the Portal is allowed to. If the Portal restricts embedding to specific sites, only those sites may sign a visitor in. The list lives in the Embedding card on the same page.

The same secret signs session context for the AI. email is simply one more key in that context, so a page that already signs context adds the email to it and nothing else changes.

Signing the email

The message you hash has a fixed layout so both sides produce the same bytes:

  1. Write every value as key=value.
  2. Sort the lines by key and join them with a newline (\n).
  3. Put the current unix time in seconds and a dot in front.

For the email alone, signed at 1757500000, the message is:

1757500000.email=jane@example.com

Hash it with HMAC-SHA256 using your secret and hex-encode the result. With the inputs below the signature is 1bc2ffc49f525ba68e3dd41b5330f4bac15ed4a64210274f12f5a22b18654344. Use them to check your implementation before wiring it up.

InputValue
Secretfwsc_9f2d7c1e4b8a6d3f0e5c7a9b1d2f4e6c8a0b3d5f7e9c1a2b4d6f8e0a2c4e6b8d
Contextemail=jane@example.com
Timestamp1757500000
Signature1bc2ffc49f525ba68e3dd41b5330f4bac15ed4a64210274f12f5a22b18654344

Adding a second key changes the message. The same secret and timestamp with email=jane@example.com and plan=pro gives fa1625cda5f12d68506edb4caf60573a5684247f3c0ac9c82429c406cf4929ce.

In Node.js

sign-in.js
import { createHmac } from "node:crypto";

export function signVisitor(secret, email, extra = {}) {
  const context = { ...extra, email };
  const ts = Math.floor(Date.now() / 1000);
  const canonical = Object.keys(context)
    .sort()
    .map((key) => `${key}=${context[key]}`)
    .join("\n");
  const signature = createHmac("sha256", secret)
    .update(`${ts}.${canonical}`)
    .digest("hex");
  return { context, signature, ts };
}

const config = signVisitor(process.env.FORMWISE_SIGNING_SECRET, "jane@example.com");

In Python

sign_in.py
import hmac, hashlib, os, time

def sign_visitor(secret: str, email: str, extra: dict[str, str] | None = None) -> dict:
    context = {**(extra or {}), "email": email}
    ts = int(time.time())
    canonical = "\n".join(f"{key}={context[key]}" for key in sorted(context))
    signature = hmac.new(
        key=secret.encode(),
        msg=f"{ts}.{canonical}".encode(),
        digestmod=hashlib.sha256,
    ).hexdigest()
    return {"context": context, "signature": signature, "ts": ts}

config = sign_visitor(os.environ["FORMWISE_SIGNING_SECRET"], "jane@example.com")

In PHP

sign-in.php
<?php
function signVisitor(string $secret, string $email, array $extra = []): array {
    $context = array_merge($extra, ['email' => $email]);
    $ts = time();
    ksort($context, SORT_STRING);
    $lines = [];
    foreach ($context as $key => $value) {
        $lines[] = $key . '=' . $value;
    }
    $canonical = implode("\n", $lines);
    $signature = hash_hmac('sha256', $ts . '.' . $canonical, $secret);
    return ['context' => $context, 'signature' => $signature, 'ts' => $ts];
}

$config = signVisitor(getenv('FORMWISE_SIGNING_SECRET'), 'jane@example.com');

Sign on the server

Never compute the signature in the browser. Doing so would put your secret in the page source, and anyone could read it and sign in as anyone. Compute it where you render the page, then write the result into the config object.

Passing the signature

Render the signed result into window.AIContextConfig on every page load, before the embed script:

<script>
  window.AIContextConfig = {
    context: { "email": "jane@example.com" },
    signature: "1bc2ffc49f525ba68e3dd41b5330f4bac15ed4a64210274f12f5a22b18654344",
    ts: 1757500000
  };
</script>
<script src="https://app.formwise.ai/embed.js" async></script>
<iframe src="https://app.formwise.ai/d/YOUR-PORTAL-KEY"></iframe>

The signature must be fresh on each render. Do not cache the page, or the fragment that holds the config, for longer than a few minutes.

The embed script also listens for the Portal's reload request. When a signed session ends and the visitor clicks Reload, the script reloads your page, your server renders a fresh signature, and the visitor is signed in again. Keep the script on the page for that reason even if you pass the values in the link.

On a custom domain, load the script from that domain, for example https://ai.yourcompany.com/embed.js, and point the iframe at it too. The script, the config object, and the messages exchanged with the iframe carry no product branding.

The values can also travel in the Portal link as context[email]=, context_sig, and context_ts. The config object is preferred, because the signed email then never appears in an address bar, browser history, or a server log.

Exact matching

The signature covers exactly the bytes you hashed, so the values you send must match them exactly.

  • Keys are lower-case. FormWise lower-cases them before checking, so send them lower-case and hash them lower-case.
  • Values are trimmed and HTML is stripped before checking. Send them trimmed.
  • Every value is hashed as text. Sort lines with a plain byte-order sort, not a locale-aware one.
  • The timestamp is unix time in seconds, not milliseconds.
  • The email's case does not matter for identity. Jane@Example.com and jane@example.com sign in the same visitor, but the signature must match the casing you sent.

What stays the same

Signed sign-in replaces the one-time code step and nothing else.

  • The Portal's gates still apply. Allowed email domains, the admission list, and trusted networks are checked exactly as they are at the code step. A refused email sees the normal sign-in screen.
  • Signing in grants nothing. The visitor reaches what their groups are assigned to, or what a plan on the attached Storefront unlocks, same as any other sign-in. See Portals as doors.
  • One identity per email. A visitor who signed in with a code last month and arrives signed today is the same user, with the same history, plans, and credits.
  • Password protection is satisfied. On a password-protected Portal a signed sign-in from your site counts as knowing the password, since your site vouched for the visitor. Note that the password page still appears on the visitor's very first load, before the Portal has had a chance to read the signature; from then on it does not.

How long a signed session lasts

A session that started with a signature only lives while a valid signature keeps accompanying it. This is what makes a copied signature weaker than a copied one-time code.

  • Every page load signs in again. When the Portal loads, any session a previous signature started is ended first. Your page produces a fresh signature on each render and the Portal signs the visitor in again. If the signature is missing or invalid on a load, the visitor is signed out and sees the sign-in screen.
  • It lasts four hours and cannot be extended. The Portal cannot ask your page for a new signature on its own, so a page left open past four hours needs a reload. A visitor who signed in with a one-time code keeps their thirty-day session as before.
  • Your site decides who the visitor is. If a visitor already signed in with a one-time code as one email and your page signs a different email, the signed email wins and replaces the session.
  • A copied signature expires. It is accepted for ten minutes from its timestamp and can never start a session after that. Any session it did start ends on the next page load.

When the session ends

The Portal handles the end of a signed session itself. Nothing on your page needs to change beyond keeping the embed script loaded.

  1. Two minutes before the end, a slim bar appears at the top of the Portal: "Your session ends in 2 minutes. Reload the page to keep working." with a Reload button. The visitor can finish what they are doing; the bar does not block anything.
  2. At the end, the Portal replaces its content with "Your session has ended. Reload the page to continue where you left off." and the same Reload button. There is nothing else to click, and the visitor is never shown the email and code form.
  3. Reload asks your page to reload itself. The embed script receives the request and reloads the page, your server renders a fresh signature, and the visitor is back in the Portal, signed in, with their conversations intact. This goes through the script because a page inside an iframe is not allowed to reload the page around it.

Two details worth knowing:

  • If your page does not load the embed script, the Portal waits a moment for a reply and then reloads only itself. Without a fresh signature that lands the visitor on the sign-in screen, so keep the script on the page even when you pass the values in the link.
  • If the visitor opened the Portal link directly rather than inside your page, Reload simply reloads the Portal.

A visitor who leaves the tab open for hours will see the "session has ended" notice when they come back, exactly as they would in most web apps. One click puts them back where they were.

Why a signed sign-in is refused

When the signed sign-in is refused the Portal falls back to the normal sign-in screen, and the browser console names the reason. Look for one of these:

  • Allow signed sign-in is off on the Portal.
  • No timestamp, or one older than ten minutes. Check that ts is unix time in seconds and that the page is not cached.
  • No email key in the signed context, or a value that is not an email address.
  • The signature does not match. Hashed in the browser instead of on the server, keys or values changed after signing, lines not sorted, the timestamp altered, or a rotated secret.
  • The email's domain is not allowed on the Portal, or the email is not admitted through the Portal's door. The Portal treats these the same way it treats them at the one-time code step.
  • The visitor's network is not on the Portal's trusted list.
  • The embedding page is not on the Portal's allowlist, so the Portal ignored the config entirely.

Need help setting this up?

Signing on the server is a small change but it touches your authentication code. If you are unsure where to compute the signature, or want a second pair of eyes on the flow, reach out to our support team with the language and framework your site uses.

On this page