SSO links
Mint single-use, signed login links from your own backend so customers land in the portal already identified.
SSO links
An SSO link logs a customer into the portal without a password or email round-trip. Your backend signs a short-lived JWT with the shared tenant secret and sends the customer to:
https://<your-portal-host>/sso/in?token=<jwt>On first use the customer goes through onboarding with their details
prefilled; on later visits the link opens the dashboard directly. Each
link is single-use: the jti claim is consumed on first use and a
replay is rejected.
The shared secret (sso_secret) is issued by Joulo when your tenant is
set up. Treat it like a password: server-side only, never in a browser
or app bundle.
Token specification
Sign a JWT with HS256 using the shared tenant secret.
| Claim | Required | Description |
|---|---|---|
sub | yes | Your stable customer number (external id). This is the key all later data delivery matches on. |
tenant | yes | Your tenant slug. Must match the portal host the link points to. |
jti | yes | Unique id per link (e.g. a UUID). Single-use: replays are rejected. |
iat | yes | Issued-at. Token age is hard-capped at 15 minutes regardless of exp. |
exp | no | Expiry, recommended at 15 minutes or less. Not required: token age is capped at 15 minutes from iat regardless. |
email | no | Customer email. Omit if unknown; the portal will ask for it as an optional step. |
name | no | Full name, shown read-only in the machtiging step. |
address | no | Street and house number. |
postal_code | no | Postal code. Used together with the address for automatic EAN lookup. |
city | no | City. |
country | no | Country code, e.g. NL. |
mid_serial | no | Serial number of the MID meter, when you know it. |
brand | no | Charger brand hint (e.g. easee). Highlights that brand in the charger-link step. |
Rules the portal enforces:
- Algorithm must be
HS256(for the default SSO mode). tenantmust equal the slug of the host the customer lands on.- Tokens older than 15 minutes (from
iat) are rejected even ifexpis further out. - A consumed
jtiis never accepted again. Mint a fresh link for every click, do not cache links. - Keep your signing server's clock NTP-synced: clock-skew tolerance is
60 seconds, so a token whose
iatlies further in the future is rejected.
Examples
import { SignJWT } from "jose";
import { randomUUID } from "node:crypto";
const secret = new TextEncoder().encode(process.env.CPO_SSO_SECRET);
async function mintSsoLink(customer) {
const token = await new SignJWT({
tenant: "demo",
email: customer.email,
name: customer.name,
address: customer.address,
postal_code: customer.postalCode,
city: customer.city,
mid_serial: customer.midSerial,
})
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setSubject(customer.customerNumber)
.setIssuedAt()
.setExpirationTime("15m")
.setJti(randomUUID())
.sign(secret);
return `https://demo.cpo.joulo.nl/sso/in?token=${token}`;
}import os, time, uuid, jwt # PyJWT
def mint_sso_link(customer):
now = int(time.time())
claims = {
"sub": customer["customer_number"],
"tenant": "demo",
"email": customer.get("email"),
"name": customer.get("name"),
"address": customer.get("address"),
"postal_code": customer.get("postal_code"),
"city": customer.get("city"),
"mid_serial": customer.get("mid_serial"),
"iat": now,
"exp": now + 900,
"jti": str(uuid.uuid4()),
}
token = jwt.encode(
# Drop optionals you do not have: a null claim is rejected.
{k: v for k, v in claims.items() if v is not None},
os.environ["CPO_SSO_SECRET"],
algorithm="HS256",
)
return f"https://demo.cpo.joulo.nl/sso/in?token={token}"Replace demo with your own tenant slug and host. Omit optional claims
you do not have • empty strings and null values are rejected.
For tenants with iDIN verification enabled, the name and address claims act as prefill only: the bank-verified data is leading, and mismatches are flagged for review.
Where to show the link
Mint the link at click time, not ahead of time:
- A button or banner in your customer portal ("Claim your ERE payout").
- A personal link in an email campaign, minted per recipient at send time and routed through a redirect endpoint on your side so the 15-minute window starts when the customer clicks.
Centrally signed tokens (on request)
If you prefer not to hold a signing secret at all, the tenant can be switched to centrally signed (RS256) mode: your backend will call a Joulo endpoint with an API key and receive a ready-to-use short-lived link in return. This mode is activated per tenant on request • contact Joulo before building against it.