> ## Documentation Index
> Fetch the complete documentation index at: https://docs.junojourney.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Portal SSO (JWT hand-off)

> Sign learners into Juno straight from your own portal — no identity provider required.

export const InternalNote = ({children}) => {
  const [isInternal, setIsInternal] = useState(false);
  useEffect(() => {
    const user = window.__mintlify_user__;
    if (user?.groups?.includes("internal")) setIsInternal(true);
  }, []);
  if (!isInternal) return null;
  return <div className="internal-note">
      <strong className="internal-note-title">🔒 Internal Note</strong>
      <div>{children}</div>
    </div>;
};

Portal SSO lets your own portal or intranet sign learners into Juno without an identity provider. Your backend signs a short-lived token with a secret you generate in Juno's admin, your page posts it to Juno, and the learner lands in Juno already signed in — full-tab or inside an iframe.

<Note>
  If your organization has a SAML 2.0 identity provider (Okta, Entra ID, Google Workspace…), use [SSO & SAML](/integrations/sso-and-saml) instead. Portal SSO is for organizations without an IdP.
</Note>

***

## How it works

1. A learner is signed into **your** portal
2. Your backend signs a JSON Web Token (JWT) containing the learner's email
3. Your page auto-submits that token to Juno with a hidden form (POST)
4. Juno verifies the token, creates or updates the learner, and redirects them into Juno — signed in

New Juno users are created as **Learners**, and any `role` claim in the token is ignored. An **existing** Juno user is signed in as themselves, with whatever role they already have.

<Warning>
  Your signing secret is effectively a master credential: anyone who can sign a token can authenticate as **any** user in your organization — including an existing admin, at that account's role. Keep the secret server-side only, and rotate it immediately if it's ever exposed.
</Warning>

***

## Setup

<Steps>
  <Step title="Ask Juno to turn on Portal SSO">
    Portal SSO is behind a feature flag. Ask your Juno customer success manager to enable it for your organization and to give you your **tenant ID** (the token `aud`). Once enabled, a **Portal SSO** section appears under **Admin → SSO settings** — that's the only step that needs Juno.
  </Step>

  <Step title="Generate a signing secret (self-serve)">
    In the **Portal SSO** section, click **Generate secret** and copy the **secret** and its **key id (`kid`)** — the secret is shown only once, so store it in your backend's secret manager (never in browser code). Then flip **Enable Portal SSO** on. You manage secrets yourself here — generate, rotate, and delete.
  </Step>

  <Step title="Sign tokens in your backend">
    Use a [backend signing example](#backend-sign-the-token) below. Tokens must be signed server-side; the secret must never reach the browser.
  </Step>

  <Step title="Add the hand-off snippet to your portal">
    Use a [frontend snippet](#frontend-hand-off-to-juno) below — React, plain JS, or server-rendered HTML; new tab or embedded iframe.
  </Step>
</Steps>

***

## Token contract

Tokens are HS256-signed JWTs. Juno enforces every rule below and rejects the hand-off otherwise.

| Claim         | Required    | Rule                                                                                                                                                    |
| ------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `email`       | Yes         | The learner's email. Matched case-insensitively; new learners are created with it                                                                       |
| `jti`         | Yes         | Unique id per token (e.g. a UUID). **One-time use** — replays are rejected                                                                              |
| `exp`         | Yes         | Expiry. At most **300 seconds** after `iat`                                                                                                             |
| `iat`         | Yes         | Issue time. At most 60 seconds in the future                                                                                                            |
| `aud`         | Yes         | Your Juno **tenant ID** (a 24-character id, provided by Juno)                                                                                           |
| `iss`         | Yes         | A stable name for your portal (e.g. `acme-portal`)                                                                                                      |
| `external_id` | Recommended | Your stable user id. Lets Juno re-match the learner if their email changes                                                                              |
| `name`        | Optional    | Display name, synced to the learner profile                                                                                                             |
| `attributes`  | Optional    | Object of standard profile fields to sync onto the learner: `firstName`, `lastName`, `jobTitle`, `department`, `location`. **Any other key is ignored** |

Set the `kid` header to the key id you received. Any `role` claim is ignored — new users are created as Learners (an existing user keeps their current role; see the security note above). The `attributes` object is allowlisted to the five fields above; nothing else (roles, permissions, ids) is ever read from it.

***

## Scaffold with an AI agent

In a hurry? Paste this into your coding assistant (Cursor, Claude, Copilot…) to scaffold the integration, then fill in the values from **Admin → SSO settings → Portal SSO**.

```text Prompt theme={null}
Integrate Juno Portal SSO so a signed-in portal user lands in Juno already authenticated.

Backend (the signing secret must never reach the browser):
- Sign a JWT with HS256 using our Juno signing secret (env JUNO_PORTAL_SSO_SECRET) and set the JWT `kid` header to our key id (env JUNO_PORTAL_SSO_KID).
- Claims: aud = our Juno tenant id (env JUNO_TENANT_ID), iss = "our-portal", email = the user's email, jti = a fresh UUID per token, iat = now, exp = now + 120s (max 300s). Optional: external_id = our stable user id, name, and an attributes object with any of { firstName, lastName, jobTitle, department, location }.
- Expose an endpoint that returns a freshly-signed token per call (tokens are single-use).

Frontend:
- POST the token as form field `jwt` (plus optional `junoRedirectUrl`) to https://api.the-juno.com/api/v1/sso/portal.
- Use target="_blank" for a new tab, or target an <iframe name> to embed Juno. Never put the token in a URL or query string.

On failure Juno redirects back with ?kind=error&code=…&message=… .
```

***

## Backend: sign the token

Sign server-side with the secret from Juno. Generate a **fresh token for every hand-off** — tokens are single-use and expire within minutes.

<CodeGroup>
  ```js Node.js theme={null}
  // npm install jsonwebtoken
  const jwt = require("jsonwebtoken");
  const crypto = require("node:crypto");

  const JUNO_TENANT_ID = process.env.JUNO_TENANT_ID; // the JWT aud
  const JUNO_PORTAL_SSO_SECRET = process.env.JUNO_PORTAL_SSO_SECRET; // keep server-side
  const JUNO_PORTAL_SSO_KID = process.env.JUNO_PORTAL_SSO_KID;

  function signJunoHandoffToken(user) {
    return jwt.sign(
      {
        email: user.email,
        external_id: user.id,
        name: user.displayName,
        iss: "acme-portal",
        aud: JUNO_TENANT_ID,
        jti: crypto.randomUUID(),
        // Optional: standard profile fields synced onto the learner.
        attributes: {
          firstName: user.firstName,
          lastName: user.lastName,
          jobTitle: user.jobTitle,
          department: user.department,
        },
      },
      JUNO_PORTAL_SSO_SECRET,
      { algorithm: "HS256", expiresIn: 120, keyid: JUNO_PORTAL_SSO_KID },
    );
  }
  ```

  ```python Python theme={null}
  # pip install pyjwt
  import os, time, uuid, jwt  # PyJWT

  JUNO_TENANT_ID = os.environ["JUNO_TENANT_ID"]  # the JWT aud
  JUNO_PORTAL_SSO_SECRET = os.environ["JUNO_PORTAL_SSO_SECRET"]  # keep server-side
  JUNO_PORTAL_SSO_KID = os.environ["JUNO_PORTAL_SSO_KID"]

  def sign_juno_handoff_token(user):
      now = int(time.time())
      return jwt.encode(
          {
              "email": user["email"],
              "external_id": user["id"],
              "name": user.get("display_name"),
              "iss": "acme-portal",
              "aud": JUNO_TENANT_ID,
              "jti": str(uuid.uuid4()),
              "iat": now,
              "exp": now + 120,
              # Optional: standard profile fields synced onto the learner.
              "attributes": {
                  "firstName": user.get("first_name"),
                  "lastName": user.get("last_name"),
                  "jobTitle": user.get("job_title"),
                  "department": user.get("department"),
              },
          },
          JUNO_PORTAL_SSO_SECRET,
          algorithm="HS256",
          headers={"kid": JUNO_PORTAL_SSO_KID},
      )
  ```

  ```php PHP theme={null}
  <?php
  // composer require firebase/php-jwt
  use Firebase\JWT\JWT;

  function sign_juno_handoff_token(array $user): string {
      $now = time();
      $payload = [
          "email" => $user["email"],
          "external_id" => $user["id"],
          "name" => $user["displayName"] ?? null,
          "iss" => "acme-portal",
          "aud" => getenv("JUNO_TENANT_ID"),
          "jti" => bin2hex(random_bytes(16)),
          "iat" => $now,
          "exp" => $now + 120,
          // Optional: standard profile fields synced onto the learner.
          "attributes" => [
              "firstName" => $user["firstName"] ?? null,
              "lastName" => $user["lastName"] ?? null,
              "jobTitle" => $user["jobTitle"] ?? null,
              "department" => $user["department"] ?? null,
          ],
      ];
      return JWT::encode(
          $payload,
          getenv("JUNO_PORTAL_SSO_SECRET"),
          "HS256",
          getenv("JUNO_PORTAL_SSO_KID"),
      );
  }
  ```
</CodeGroup>

***

## Frontend: hand off to Juno

Your frontend fetches the signed token from your backend, then POSTs it to `https://api.the-juno.com/api/v1/sso/portal`. Set the form's `target` to `_blank` for a **new tab**, or to an `<iframe name>` to **embed** Juno in your page. The optional `junoRedirectUrl` deep-links the learner (a path like `/unit/123`, or an absolute URL on your Juno origin).

<Warning>
  The token is minted by **your backend** (which holds the secret) — never sign in the browser. And never put the token in a URL or query string: Juno rejects it, and tokens in URLs leak through logs and history. Always POST it in the form body.
</Warning>

<CodeGroup>
  ```jsx React theme={null}
  import { useRef } from "react";

  const JUNO_SSO_URL = "https://api.the-juno.com/api/v1/sso/portal";

  // Embeds Juno in an iframe. Clicking the button fetches a freshly-signed token
  // from YOUR backend and posts it into the iframe. For a new tab instead, set
  // target="_blank" and drop the <iframe>.
  export function JunoEmbed({ junoRedirectUrl = "/career" }) {
    const formRef = useRef(null);
    const jwtRef = useRef(null);

    async function openJuno() {
      const { jwt } = await fetch("/api/juno-sso-token").then((r) => r.json());
      jwtRef.current.value = jwt; // token comes from your server, never the browser
      formRef.current.submit();
    }

    return (
      <>
        <button onClick={openJuno}>Open Juno</button>
        <iframe name="juno-frame" title="Juno" style={{ width: "100%", height: 800, border: 0 }} />
        <form ref={formRef} method="POST" action={JUNO_SSO_URL} target="juno-frame" hidden>
          <input ref={jwtRef} type="hidden" name="jwt" />
          <input type="hidden" name="junoRedirectUrl" value={junoRedirectUrl} />
        </form>
      </>
    );
  }
  ```

  ```js Plain JS theme={null}
  const JUNO_SSO_URL = "https://api.the-juno.com/api/v1/sso/portal";

  // Fetch a freshly-signed token from YOUR backend, then POST it to Juno.
  // target: "_blank" opens a new tab; pass an <iframe name> to embed instead.
  async function junoHandoff({ target = "_blank", junoRedirectUrl = "/career" } = {}) {
    const { jwt } = await fetch("/api/juno-sso-token").then((r) => r.json());

    const form = document.createElement("form");
    form.method = "POST";
    form.action = JUNO_SSO_URL;
    form.target = target;
    form.style.display = "none";

    const add = (name, value) => {
      const input = document.createElement("input");
      input.type = "hidden";
      input.name = name;
      input.value = value;
      form.appendChild(input);
    };
    add("jwt", jwt);
    add("junoRedirectUrl", junoRedirectUrl);

    document.body.appendChild(form);
    form.submit();
    form.remove();
  }

  // New tab:  junoHandoff();
  // Embed:    add <iframe name="juno-frame"></iframe>, then junoHandoff({ target: "juno-frame" });
  ```

  ```html Server-rendered HTML theme={null}
  <!--
    <%= %> is server-side template interpolation (EJS/ERB, or {{ }} in Blade/Jinja):
    your backend renders the signed token into the value before sending the page.
    For an iframe embed, add target="juno-frame" + an <iframe name="juno-frame">.
  -->
  <form id="juno-sso" method="POST" action="https://api.the-juno.com/api/v1/sso/portal">
    <input type="hidden" name="jwt" value="<%= signJunoHandoffToken(user) %>" />
    <input type="hidden" name="junoRedirectUrl" value="/career" />
  </form>
  <script>document.getElementById("juno-sso").submit();</script>
  ```
</CodeGroup>

***

## Error codes

Failed hand-offs redirect to the Juno app root with `kind=error&code=…&message=…`:

| Code             | Meaning                                                                                    |
| ---------------- | ------------------------------------------------------------------------------------------ |
| `sso_disabled`   | Portal SSO is not enabled for the organization                                             |
| `invalid_token`  | Bad signature, wrong `aud`, missing `exp`/`iat`, lifetime over 300s, or future-dated `iat` |
| `expired_token`  | The token's `exp` has passed                                                               |
| `replayed_token` | The `jti` was already used — sign a fresh token per hand-off                               |
| `missing_claim`  | `email`, `jti`, or `iss` is missing                                                        |
| `unknown_kid`    | The `kid` header doesn't match an active secret                                            |
| `server_error`   | Unexpected failure — contact support                                                       |

***

## Secret rotation

Two secrets can be active at once, each with its own `kid` — so you rotate with zero downtime, all self-serve in the **Portal SSO** section:

1. Click **Generate secret** to add a second secret (you'll now have two active)
2. Switch your backend to the new secret and `kid`
3. Delete the old secret

<InternalNote>
  Admins self-serve everything from **Admin → SSO settings → Portal SSO**, gated by the `can_manage_portal_sso` feature flag (default 99). CS's only job is to enable that flag and hand over the tenant id — no CS involvement in secret generation/rotation. The UI calls these IT\_ADMIN routes under `/api/v1/security`:

  * `GET /portal-sso-config` — masked config (kids + createdAt only)
  * `POST /portal-sso-config/secrets` — generate (256-bit, plaintext returned once; max 2 active)
  * `DELETE /portal-sso-config/secrets/:kid` — remove (blocked for the last secret while enabled)
  * `PUT /portal-sso-config` — `{ "enabled": true|false }` (enable requires ≥1 secret)

  The token `aud` is the tenant's **id** (`tid` = TenantAuth `_id`) — give it to the customer when enabling the flag. It's used instead of the subdomain so it survives subdomain renames and works with custom domains; the hand-off resolves the tenant by `findById(aud)` and derives the redirect host from that record.

  The signing secret is AES-encrypted at rest. Tokens are verified with the alg pinned server-side (HS256), one-time `jti` enforced in Mongo across instances. JIRA: TECHNICAL-1024.
</InternalNote>
