JouloDocs

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.

ClaimRequiredDescription
subyesYour stable customer number (external id). This is the key all later data delivery matches on.
tenantyesYour tenant slug. Must match the portal host the link points to.
jtiyesUnique id per link (e.g. a UUID). Single-use: replays are rejected.
iatyesIssued-at. Token age is hard-capped at 15 minutes regardless of exp.
expnoExpiry, recommended at 15 minutes or less. Not required: token age is capped at 15 minutes from iat regardless.
emailnoCustomer email. Omit if unknown; the portal will ask for it as an optional step.
namenoFull name, shown read-only in the machtiging step.
addressnoStreet and house number.
postal_codenoPostal code. Used together with the address for automatic EAN lookup.
citynoCity.
countrynoCountry code, e.g. NL.
mid_serialnoSerial number of the MID meter, when you know it.
brandnoCharger 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).
  • tenant must equal the slug of the host the customer lands on.
  • Tokens older than 15 minutes (from iat) are rejected even if exp is further out.
  • A consumed jti is 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 iat lies 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.

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.