Serving Tenant-Branded, Login-Free Web Pages from a Lambda with Tokenized Access
A recipient needs to open a document you sent them. They have no account, and you do not want them to create one. The document is sensitive, for example a medical report, so it must be delivered in the sender's branding, it must reflect the current state of the case, and it must be revocable the moment something changes. A static link cannot do all three at once.
The obvious answers fail for concrete reasons. This post walks through the design we use in production for exactly this case: a token-based, time-limited, server-rendered HTML page that the recipient opens without logging in, backed by a single Lambda and DynamoDB.
Why the easy options do not work
A PDF attached to an email. No branding control once it leaves your system, no state, and impossible to revoke. The PDF lives in the recipient's inbox forever. If the case changes, the document is already wrong.
A static S3 presigned link. Better, but a presigned URL is a long-lived bearer credential. To make it usable you stretch the expiry, which weakens it. It points at a raw file, so there is no branding, no surrounding page, no action buttons, and no way to invalidate it before it expires.
A real login (Cognito, magic links to an account). Correct security model, wrong cost. Forcing a patient to create an account to read one report is friction nobody accepts, and you now own identity for people who are not your users.
The requirement is the page must be branded, state-dependent, and securely revocable. That combination forces a dynamically rendered page plus token mechanics. Everything below follows from those three words.
Architecture
Two HTTP-facing flows, one Lambda service behind an HTTP API.
SEND (triggered from the workflow)
renderAndSend()
render the template with the current data
mint a cryptographically random token (unique via DDB ConditionExpression)
store rendered HTML + status PENDING in DynamoDB
notify recipient with the token URL
RETRIEVE (recipient clicks the link)
GET /form/{tenant}/{token}
API Gateway -> Lambda
-> getPage(token)
serve HTML based on status (PENDING / COMPLETED / CANCELLED / EXPIRED)
The page is rendered at send time, not at click time. That keeps retrieval cheap and makes the page deterministic.
Minting the token at send time
When the workflow reaches a step that needs input from an external recipient, the service pulls the current data, renders the templates, and mints a token. Because that token is the only thing standing between the public internet and a sensitive document, it has to be a secret, not just unique. We use 256 bits from a CSPRNG, encoded URL-safe with base64url. Uniqueness is enforced by the database, not by hope.
const crypto = require('crypto');
async function mintTask(tenant, renderedHtml, taskTimeoutSeconds) {
const token = crypto.randomBytes(32).toString('base64url');
const now = Date.now();
await ddb.put({
TableName,
Item: {
pk: `EXT_TASK#${token}`,
sk: 'EXT_TASK#META',
tenant,
status: 'PENDING',
html: renderedHtml,
createdAt: now,
expiresAt: now + taskTimeoutSeconds * 1000,
},
ConditionExpression: 'attribute_not_exists(pk)',
});
return `https://forms.example.com/form/${tenant}/${token}`;
}
A word on what we deliberately did not use here: a ULID or UUIDv7. They are excellent identifiers, but they are not secrets. The leading 48 bits of a ULID are a millisecond timestamp, and the format is designed to be sortable and roughly guessable in time order. That is the opposite of what you want for a capability URL guarding a medical document. The rule of thumb: a sortable id is for your database, a random token is for your access control. We still use ULIDs internally for non-secret record ids, just never as the access token.
The ConditionExpression is the uniqueness guarantee. A 256-bit collision is already astronomically unlikely, but the conditional write means a duplicate can never silently overwrite an existing task.
Status-conditional retrieval
GET /form/{tenant}/{token} resolves to getPage(token). The response is always text/html; charset=utf-8, but the body depends on the stored status.
async function getPage(token) {
const item = await loadTask(token);
if (!item) return notFound();
if (item.expiresAt && Date.now() > item.expiresAt) {
return html(expiredPage());
}
switch (item.status) {
case 'PENDING': return html(item.html);
case 'COMPLETED': return html(completedPage());
case 'CANCELLED': return html(cancelledPage());
case 'EXPIRED': return html(expiredPage());
default: return notFound();
}
}
Expiry is checked on read, so a task that timed out shows the expired page even before the backend cleanup job flips its status to EXPIRED. The timeout itself comes from a configurable taskTimeoutSeconds: expiresAt = now + taskTimeoutSeconds * 1000. A timed-out task can either fail (so the workflow can react and take an alternate path) or continue, depending on configuration.
Completing the task and resuming the process
When the recipient submits, POST /form/{tenant}/{token} calls completeTask({ token, selectedAction, formData }). The action buttons in the page are rendered as server-side POST forms, never client-trusted handlers, so the recipient cannot fabricate a state transition. The status must be PENDING. If it is anything else, the user gets the "already handled" page instead of a second submission.
async function completeTask({ token, selectedAction, formData }) {
await ddb.update({
TableName,
Key: { pk: `EXT_TASK#${token}`, sk: 'EXT_TASK#META' },
UpdateExpression: 'SET #s = :completed',
ConditionExpression: '#s = :pending',
ExpressionAttributeNames: { '#s': 'status' },
ExpressionAttributeValues: { ':completed': 'COMPLETED', ':pending': 'PENDING' },
});
const output = {
selectedAction,
formData,
meta: {
completedAt: new Date().toISOString(),
completedBy: 'external',
},
};
// hand the result back to the paused workflow via its stored callback token
await resumeWorkflow(storedCallbackToken, output);
}
The ConditionExpression: '#s = :pending' is the replay guard. Two parallel submits race on the same conditional update, exactly one wins, and the loser is rejected by DynamoDB. After the write, the stored callback token is handed back to the workflow engine and the waiting process continues from where it paused.
The security model in one list
- Access is the token. A 256-bit cryptographically random token in the URL, no account, no login.
- Every write is status-conditional. No overwrite, no replay after completion.
- Status enforcement. Expired, completed, and cancelled tasks reject further input.
- Tenant isolation. Every path carries
{tenant}.
Conclusion
The three requirements (branded, state-dependent, revocable) are what rule out the simple options and pull you toward this shape. Render the page once at send time, address it with an unguessable random token, and gate every transition with a conditional write. The recipient gets a clean branded page with no account, and the sender keeps full control over state and revocation. A durable workflow engine makes the human step a normal, resumable part of the process.
This external user task pattern runs in production at Pantarey, where Sension GmbH delivers around 100 medical reports a day this way. The full case is written up in the Sension success story.