Sign a user into Pylon from a trusted server (Stripe Checkout success, custom IdP, internal SSO bridge) using an HMAC-signed request.
POST /api/auth/sessions/trusted-mint lets a server you control mint a Pylon session for a given email — no password, no magic link, no OAuth roundtrip. It’s the right tool when another trusted system has already verified the user’s identity and the only thing left is to land them in your app signed in.The canonical use case: Stripe Checkout success. Stripe verified the buyer’s email + collected payment; forcing them through a magic-link dance between “paid” and “in the dashboard” costs measurable conversion.
The endpoint is HMAC-gated, not session-gated. Anyone who knows PYLON_TRUSTED_SECRET can sign in as any user. Treat the secret like a database password — env-only, rotated when it leaks, never logged.
The endpoint is off by default. Until you set PYLON_TRUSTED_SECRET, requests get a plain 404 so plain Pylon installs don’t ship a signing surface that does nothing.
# 32+ bytes of CSPRNG. macOS / Linux:openssl rand -hex 32# → 2f1e... (use that)# Pylon Cloud:pylon secrets set PYLON_TRUSTED_SECRET=2f1e...# Self-hosted: set the env var on your Pylon server.
The same secret has to be available to whatever code is signing requests (your Next.js route handler, your Worker, your CLI tool). Use whatever secret-management approach you already use for STRIPE_SECRET_KEY — the threat models are identical.
When true, a missing user is provisioned with emailVerified set to now. When false, a missing user returns 400 USER_NOT_FOUND.
displayName
no
Used on user creation. Falls back to the email address. Ignored when the user already exists.
intent
no
Free-form audit string. Stored as metadata.intent on the resulting audit event so an operator can later filter “all trusted mints from Stripe Checkout success”.
Hex output is lowercase. Timestamps are Unix seconds; requests outside ±5 minutes of the server clock return 401 STALE_TIMESTAMP. The format mirrors Stripe webhook signatures intentionally — if you’ve shipped a Stripe webhook handler, you’ve shipped this.
On 2xx, the endpoint also emits the same Set-Cookie header that /api/auth/magic/verify would emit, so a browser-facing proxy can forward the response directly and the user lands signed in. The Pylon Session is created identically to a magic-link sign-in — revocable from /api/auth/sessions, listed under the user’s devices, etc.
Multi-tenant orgs. The minted session is exactly what /api/auth/magic/verify would produce — no tenant is auto-selected. Apps that need a tenant should call /api/auth/select-org after the user lands (the dashboard typically does this automatically based on a stored “last org” hint).Locked / banned users. If the User row has a non-null disabledAt, bannedAt, lockedAt, or _deletedAt column, the endpoint returns 403 ACCOUNT_LOCKED and audits a sign_in_failed event. These are conventions, not framework-enforced columns — apps that don’t model lockouts can ignore them.Email already in use under a guest cookie. Trusted-mint does not automatically merge guest data. That behavior is specific to magic-link verify (where the user explicitly proves email control). If you need anonymous-cart-merge on Stripe success, do it explicitly in your handler after the mint succeeds.Cookie pass-through to the browser. The endpoint always emits Set-Cookie even when called server-to-server. If you’re not going to forward the header to a browser, ignore it — the token in the response body is the same session and works as Authorization: Bearer pylon_….Rate limiting. The standard PYLON_RATE_LIMIT_MAX per-IP anonymous bucket applies. A bot that captures the endpoint URL but doesn’t have the secret will hit the limit within seconds and won’t make meaningful progress; the secret is the actual security boundary.
Signature, timestamp, or account-lock check failed.
sign_up
method: trusted_mint, intent: <intent>
New user was provisioned via createIfMissing: true.
sign_in
method: trusted_mint, intent: <intent>
Session minted (always, after success).
The intent field is the free-form string from the request body — use it to discriminate “Stripe checkout success” from “internal SSO bridge” from “BYOC migration backfill” without parsing audit reasons.
Replay window — be honest about what HMAC + timestamp buys
The signature scheme protects against tamper (an attacker can’t change the email in a captured request without invalidating the sig) and stale replay (a signature is dead after 5 minutes). It does NOT protect against fresh replay.A captured signed request, replayed within 5 minutes by anyone who can reach the Pylon ingress, mints another session for the same email — and the response body includes a long-lived token. So:
A TLS-MITM in the path (corporate proxy, “log everything” CDN that captures bodies) within the 5-min window can extract a usable session token.
An attacker who steals one signed request from server logs that capture the URL + headers (rare — sigs are headers, bodies aren’t usually logged) can replay it once per minute for up to 5 minutes.
Mitigations:
Don’t log signed requests with their headers.
Use HTTPS end-to-end; the HMAC is not a substitute for TLS.
If your threat model requires single-use semantics, include a nonce in the request body and enforce uniqueness in your own backend before issuing the trusted-mint call. The trusted-mint endpoint itself does not de-duplicate; Pylon’s audit log will show every replay.
The trade-off is deliberate: a per-request nonce store would add complexity and a failure mode (nonce-store unavailable → endpoint fails). For Stripe-Checkout-style use cases, the 5-min window is fine — the user is actively in the flow, not someone else.
PYLON_TRUSTED_SECRET is 32+ bytes of CSPRNG output (openssl rand -hex 32).
The secret is set only on machines that need to sign or verify — sign-only frontends do not need to know it.
The endpoint is reachable only from your trusted server-side environments (most production Pylon deployments are public, so the secret is the only thing keeping attackers out — accept that).
Server clocks are within a few seconds of NTP. ±5 min sounds generous, but a 7-min clock skew bricks the endpoint.
Rotation playbook: set a new PYLON_TRUSTED_SECRET, redeploy the signing service, wait for in-flight signed requests to flush (~10 min), then rotate the Pylon side. Pylon currently accepts one active secret; multi-key rotation is on the roadmap.
Audit log shipped somewhere queryable (Tinybird, Loki, Datadog) so a sign_in_failed spike on the trusted_mint method is alertable. Every post-signature rejection (invalid JSON, missing/invalid email, USER_NOT_FOUND, INVALID_USER_ROW, USER_INSERT_FAILED, ACCOUNT_LOCKED) emits one with the matching meta.reason.
Signed-request payloads aren’t logged at the TLS-terminator (Cloudflare, Fly proxy, etc.) — bodies are sensitive even though headers usually aren’t.
A long-lived service bearer token would work for the same use case, but:
Service tokens leak. Once leaked, the attacker can sign in as anyone forever — same blast radius as PYLON_TRUSTED_SECRET, but the secret never travels with the request, so observability tools that log headers don’t accidentally capture it.
Service tokens don’t bind to the request body. An attacker who captures one signed request can replay it. HMAC + timestamp kills that.
Token rotation requires a coordinated cutover on both sides. Secret rotation is a single env-var change on each side; you don’t have to deploy a new long-lived credential.
If your use case is “this trusted server should be able to talk to Pylon as a specific user,” pk.* API keys are the right primitive. Trusted-mint is for “this trusted server should be able to sign in any user” — a deliberately different threat model.