Skip to content

Sign in from your app

Sign the person already signed in to your own app into a tablewalk App, with no second login.

{ "name": "portal", "kind": "embed", "issuer": "https://portal.example.com",
"secret": "${PORTAL_EMBED_SECRET}", "origins": ["https://portal.example.com"] }

Your server signs a minute-long assertion of who is there:

import { createHmac, randomUUID } from 'node:crypto';
const part = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
export function assertionFor(user, page = 'https://portal.example.com') {
const now = Math.floor(Date.now() / 1000);
const body = `${part({ alg: 'HS256', typ: 'JWT' })}.${part({
iss: 'https://portal.example.com', aud: 'compass', origin: page, sub: user.id,
email: user.email, email_verified: true, name: user.name, jti: randomUUID(), iat: now, exp: now + 60,
})}`;
return `${body}.${createHmac('sha256', process.env.PORTAL_EMBED_SECRET).update(body).digest('base64url')}`;
}

In Node, mintEmbedAssertion from tablewalk/handler signs the same assertion, HS256 with a secret or ES256 and EdDSA with a private key:

import { mintEmbedAssertion } from 'tablewalk/handler';
const assertion = await mintEmbedAssertion({
issuer: 'https://portal.example.com', key: process.env.PORTAL_EMBED_SECRET, app: 'compass',
origin: 'https://portal.example.com', subject: user.id, email: user.email, emailVerified: true, name: user.name,
});

Your page posts it to the App and goes on signed in:

const reply = await fetch('https://crm.example.com/api/auth/embed/exchange', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ assertion }),
});
const { token } = await reply.json(); // only from another origin: send it as `Authorization: Bearer <token>`
  • On the App’s own origin (you proxy or mount it): the App’s ordinary session cookie, the same grants as a password sign-in, and no sign-in screen.
  • From another origin in origins: no cookie, but a bearer good for an hour, only from that page and only on the App’s browser routes. The bearer is not the session cookie: it opens a session of its own that lapses with it, so a copy replayed as a cookie after the hour is nobody.
  • Use it when your own app already knows who is there and the App sits inside it, behind your proxy or in your pages.
  • Use an identity provider instead when people should sign in at the App itself with your organization’s accounts.
Entry key What it does Default Status
issuer Exactly the assertions’ iss required Stable
secret or jwks A ${VARIABLE} holding an HS256 secret of 32 bytes or more, or your JWKS URL (ES256, EdDSA); a literal secret is refused one required Stable
origins The pages that may exchange; an assertion names one in its origin claim required Stable
apps The App ids (aud) it may sign people into every App Stable
accounts separate keeps your subject apart from existing accounts; link-verified-email links one when both sides verified the address separate Stable
createAccounts false signs in only accounts that exist already accounts made at first sign-in Stable
roles { "<App role>": ["<your role>"] }: the assertion’s roles claim counts only through this map ignored Stable
insecure Allows an http JWKS on a trusted network false Stable

The server checks the signature, issuer, audience, origin, a lifetime of five minutes at most and thirty seconds of clock skew, and takes each jti once, kept in the App’s auth store so every instance on it refuses a replay. Each issuer has a budget of thirty exchanges a minute per client address. A tenant App’s exchange works the same way from either origin and gives no membership. POST /api/auth/embed/sign-out ends the session when yours ends: with the bearer from another origin, or the cookie from the App’s own page.

  • assertion_origin → the origin claim must be the page that posts it, and listed in origins.
  • rate_limited → exchange once per page load and keep the session; a bearer lasts an hour.
  • account_exists → an account already has that email: set "accounts": "link-verified-email" and send email_verified: true.
  • [embed-issuer-no-auth] → the App does not sign people in: give it auth.