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

# SSO Integration (JWT)

> How to integrate Single Sign-On with Terang AI using RS256 JWT tokens

## Overview

Terang AI supports Single Sign-On (SSO) via signed JWT tokens. Your organization signs a JWT with your **private key**, and Terang AI verifies it using your **public key**.

This allows your members to access Terang AI directly from your platform without creating a separate account.

<Warning>
  Before you can make any SSO or API requests, the following must be **registered and whitelisted** by the Terang AI team:

  1. **Domain (`iss`)** — your organization's domain used as the JWT issuer (e.g. `iai.or.id`)
  2. **Server IP address(es)** — the outbound IP(s) that will send requests to `api.terang.ai`

  Please contact us at [founders@terang.ai](mailto:founders@terang.ai) with your domain and server IPs to get started. CIDR notation (e.g. `103.x.x.0/24`) is supported.
</Warning>

### Finding Your Server IP

The IP address you need to whitelist is the **outbound IP** of your server — the IP that `api.terang.ai` sees when your server makes a request.

<Note>
  The IP whitelist is enforced on the **direct connection to `api.terang.ai`**. If your integration redirects the user's browser with the JWT in the URL (e.g. `window.location = "https://api.terang.ai/sso/verify?token=..."`), the gateway will see **the user's browser IP**, not your server's IP, and the request will be rejected.

  To use IP whitelisting you must deliver the JWT server-to-server — for example by proxying the redirect through your backend so that the HTTP request to `api.terang.ai` originates from your whitelisted server. If this is not possible for your platform, contact us to discuss alternatives.
</Note>

<AccordionGroup>
  <Accordion title="VM / VPS (DigitalOcean, Linode, etc.)">
    Use the **public IP address** of your server. You can find it by running:

    ```bash theme={null}
    curl ifconfig.me
    ```
  </Accordion>

  <Accordion title="Google Cloud (GKE / Kubernetes)">
    GKE pods use ephemeral IPs by default, which change on restart. Set up **Cloud NAT** to get a static outbound IP:

    1. Go to **Network Services → Cloud NAT** in the GCP Console
    2. Create a NAT gateway for your VPC and region
    3. Choose **Manual IP** and assign a static IP
    4. Share that static IP with us

    This ensures all outbound traffic from your cluster uses a predictable IP.
  </Accordion>

  <Accordion title="AWS (EKS / EC2)">
    * **EC2**: Use the instance's **Elastic IP** (public static IP)
    * **EKS**: Set up a **NAT Gateway** in your VPC with an Elastic IP. All pod traffic will route through it.
      1. Go to **VPC → NAT Gateways** in the AWS Console
      2. Create a NAT Gateway with an Elastic IP
      3. Update your private subnet route table to route `0.0.0.0/0` through the NAT Gateway
      4. Share the Elastic IP with us
  </Accordion>

  <Accordion title="Serverless (Vercel, Railway, etc.)">
    Most serverless platforms use shared, rotating IPs which cannot be whitelisted. Options:

    * Use a **proxy service** with a static IP (e.g. QuotaGuard, Fixie)
    * Move SSO/API calls to a dedicated server with a static IP
    * Contact us to discuss alternative authentication methods
  </Accordion>
</AccordionGroup>

```mermaid theme={null}
graph LR
    subgraph Your["Your Platform"]
        App["Your App"]
    end

    subgraph Terang["Terang AI"]
        API["api.terang.ai<br/>(Gateway)"]
        LMS["LMS Platform"]
    end

    App -->|"1. Sign JWT (RS256)"| App
    App -->|"2. Backend calls gateway (server-to-server)"| API
    API -->|"3. Returns 302 + Location header"| App
    App -->|"4. Forward 302 to user's browser"| LMS
    LMS -->|"5. Verify JWT, create session, land on dashboard"| LMS

    style API fill:#4A90D9,stroke:#3B7DD8,color:#fff
    style LMS fill:#2B5A8F,stroke:#1E3A5F,color:#fff
```

## How it works

```mermaid theme={null}
sequenceDiagram
    participant User
    participant Partner as Your Platform
    participant API as api.terang.ai (Gateway)
    participant LMS as Terang AI LMS

    User->>Partner: Click "Open LMS"
    Partner->>Partner: Sign JWT with your private key (RS256)
    Partner->>API: Backend HTTP GET /sso/verify?token=JWT (no-follow)
    API->>API: Peek at JWT issuer (iss), check IP whitelist
    API->>Partner: 302 Found, Location: LMS URL
    Partner->>User: Forward 302 to browser
    User->>LMS: Browser follows redirect chain
    LMS->>LMS: Verify JWT signature & claims
    LMS->>LMS: Create/find user, create session
    LMS->>User: Set cookie + land on dashboard
```

## Base URLs

| Environment     | URL                         |
| --------------- | --------------------------- |
| **Production**  | `https://api.terang.ai`     |
| **Development** | `https://api.dev.terang.ai` |

## JWT Specification

### Header

Use the **RS256** algorithm. The `kid` field must match the key ID registered with Terang AI (or the `kid` in your JWKS):

```json theme={null}
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "key-1"
}
```

| Field | Type     | Max Length | Required | Description                                                                                                                                           |
| ----- | -------- | ---------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `alg` | `string` | 5 chars    | Yes      | Must be `RS256`                                                                                                                                       |
| `typ` | `string` | 3 chars    | Yes      | Must be `JWT`                                                                                                                                         |
| `kid` | `string` | 128 chars  | Yes      | Key ID — used to look up the correct public key from your JWKS. Must match a `kid` in your registered JWKS, or the key shared directly with Terang AI |

### Payload

```json theme={null}
{
  "iss": "your-domain.com",
  "aud": "terang.ai",
  "sub": "member",
  "email": "andi@your-domain.com",
  "name": "Andi Wijaya",
  "membershipId": "0001234",
  "iat": 1710000000,
  "exp": 1710000300,
  "jti": "TOKEN_MD5_HASH"
}
```

| Field          | Type     | Max Length | Required | Description                                                                                                                                                                                                                                          |
| -------------- | -------- | ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `iss`          | `string` | 253 chars  | Yes      | Your domain (e.g. `iai.or.id`). Max 253 per RFC 1035 domain name limit                                                                                                                                                                               |
| `aud`          | `string` | 9 chars    | Yes      | Must be exactly `terang.ai`                                                                                                                                                                                                                          |
| `sub`          | `string` | 100 chars  | Yes      | **Member tier** — one of `member`, `umum`, or `mahasiswa`. Determines the pricing tier the LMS applies (member → anggota price, `mahasiswa` → mahasiswa price, `umum` → public price). See [Tier & identity](#tier--identity) below                  |
| `email`        | `string` | 254 chars  | Yes      | Member's email address. Max 254 per RFC 5321                                                                                                                                                                                                         |
| `name`         | `string` | 255 chars  | No       | Member's display name. Used as the user's name on first login. Falls back to the local part of `email` if omitted                                                                                                                                    |
| `membershipId` | `string` | 255 chars  | Yes¹     | The member's identifier in your system. Its **value depends on `sub`**: `no_anggota` for `member`, NIK for `umum`, NIM for `mahasiswa`. Stored in the LMS user record and used as-is when calling the partner (IAI) APIs for purchase & certificate  |
| `iat`          | `number` | 10 digits  | Yes      | Issued at (Unix timestamp)                                                                                                                                                                                                                           |
| `exp`          | `number` | 10 digits  | Yes      | Expiration (Unix timestamp, max 5 minutes after `iat`)                                                                                                                                                                                               |
| `jti`          | `string` | 64 chars   | Yes      | **JWT ID**. Must be a unique token/session ID generated by your system (e.g., your internal `signon` MD5 hash = 32 chars, or a UUID = 36 chars). This maps the Terang LMS session directly to your authentication event and prevents replay attacks. |

¹ `membershipId` is required for any member who will purchase courses or receive
certificates (the LMS forwards it to the partner API to identify the member).

### Tier & identity

The LMS distinguishes three tiers via the **`sub`** claim, and reads the matching
identifier from the single **`membershipId`** field. Send the value that applies
to the user — the partner (IAI) resolves whichever it is on their side, so the
purchase and certificate APIs do not change per tier.

| `sub`       | LMS tier  | `membershipId` holds | Pricing         |
| ----------- | --------- | -------------------- | --------------- |
| `member`    | anggota   | `no_anggota`         | member price    |
| `umum`      | umum      | NIK (16-digit)       | public price    |
| `mahasiswa` | mahasiswa | NIM                  | mahasiswa price |

```jsonc theme={null}
// umum
{ "iss": "...", "aud": "terang.ai", "sub": "umum",      "membershipId": "3279060000000001", "email": "...", "name": "...", "iat": ..., "exp": ..., "jti": "..." }
// mahasiswa
{ "iss": "...", "aud": "terang.ai", "sub": "mahasiswa", "membershipId": "20260123",         "email": "...", "name": "...", "iat": ..., "exp": ..., "jti": "..." }
```

<Note>
  `sub` is the authoritative source of the pricing tier on the LMS side. Keep its
  value to exactly `member` / `umum` / `mahasiswa`.
</Note>

<Warning>
  The `exp` must be at most **5 minutes** after `iat`. Tokens with a longer expiry will be rejected.
</Warning>

## Public Key Exchange

The Terang AI LMS needs your **public key** to verify JWT signatures. The gateway itself does not perform cryptographic verification — it only routes requests based on the `iss` claim.

You have two options for sharing your public key:

### Option 1: JWKS Endpoint (Recommended)

Expose your public key at a standard JWKS endpoint. This supports **key rotation** automatically — when you rotate keys, Terang AI picks up the new key without any manual update.

```
GET https://your-domain.com/.well-known/jwks.json
```

<Note>
  The path and extension are convention, not a hard requirement. Any URL that returns the JWKS JSON over HTTPS works — for example `…/jwks.php` or `…/jwks` is fine if your stack serves it from there. Just share the exact URL with us.
</Note>

Response format:

```json theme={null}
{
  "keys": [
    {
      "kty": "RSA",
      "kid": "key-id-1",
      "use": "sig",
      "alg": "RS256",
      "n": "0vx7agoebGcQ...",
      "e": "AQAB"
    }
  ]
}
```

| Field | Type     | Max Length  | Description                                                   |
| ----- | -------- | ----------- | ------------------------------------------------------------- |
| `kty` | `string` | 3 chars     | Key type, must be `RSA`                                       |
| `kid` | `string` | 128 chars   | Unique key ID — used to match against the JWT header's `kid`  |
| `use` | `string` | 3 chars     | Must be `sig` (signature)                                     |
| `alg` | `string` | 5 chars     | Must be `RS256`                                               |
| `n`   | `string` | \~342 chars | RSA modulus, Base64url-encoded. For a 2048-bit key: 342 chars |
| `e`   | `string` | 4 chars     | RSA exponent, Base64url-encoded. Typically `AQAB` (4 chars)   |

Share your JWKS URL with the Terang AI team, and we will configure it on our end.

<Tip>
  Need help generating the JWKS response? See our [Implementation Examples](#implementation-examples) for copy-paste code in PHP and Node.js.
</Tip>

### Option 2: Share Public Key Directly

If you cannot host a JWKS endpoint, you can share the PEM-formatted public key directly with the Terang AI team.

```
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----
```

<Warning>
  With this option, key rotation requires manual coordination. You must notify the Terang AI team **before** rotating your key, otherwise JWT verification will fail. We strongly recommend Option 1 for production use.
</Warning>

## SSO Redirect

Once the JWT is ready, your **backend** (not the user's browser) must call the gateway:

```
GET https://api.terang.ai/sso/verify?token=<JWT_TOKEN>
```

<Warning>
  **Do not redirect the user's browser directly to `api.terang.ai/sso/verify`** (e.g. `header('Location: https://api.terang.ai/sso/verify?token=...')` or `window.location = ...`).

  The gateway's IP whitelist checks the IP of whoever hits it. If your browser forwards the user there, the gateway sees the **user's IP** and will reject the request with `403 IP x.x.x.x is not whitelisted`.

  Always call the gateway from your backend and forward the `Location` header to the user's browser. See the [implementation examples](#implementation-examples) below.
</Warning>

### Gateway behavior

The gateway (`api.terang.ai`) does **not** verify the JWT itself. It:

1. Peeks at the `iss` claim to identify your organization
2. Checks that the request comes from a whitelisted IP (your backend's outbound IP)
3. Returns a **HTTP 302 response** with a `Location` header pointing to the Terang AI LMS

Your backend must then **forward** that `Location` header to the user's browser (via its own 302 response) so the browser can follow the rest of the redirect chain — that is where the LMS session cookie is set.

### LMS behavior

The LMS then:

1. Verifies the JWT signature using your registered public key
2. Validates `iss`, `aud`, `exp`, and `jti` claims
3. Finds or creates the user account based on `email` and `membershipId`
4. Creates a session (sets a cookie on the user's browser) and redirects the user to the dashboard

### Success response

On success, the redirect chain the user's browser follows looks like this:

```
<your-backend>     → 302 → <your-lms-host>/sso/verify?token=...           (forwarded from gateway)
<your-lms-host>    → 307 → <your-lms-host>/api/auth/sso/callback?token=... (session cookie set here)
<your-lms-host>    → 302 → <your-lms-host>/dashboard
```

`<your-lms-host>` is the per-partner LMS host the gateway routes to based on your `iss` claim — for example, `lms.iai.or.id` for IAI. Whatever URL the gateway returns in the `Location` header is what you forward to the user's browser.

Both new and existing SSO users land on `/dashboard` on the first hop they hit after the session cookie is set. There is no separate password-setup step for SSO users.

<Warning>
  Your HTTP client must **not** auto-follow redirects when calling `api.terang.ai/sso/verify`. If it does (e.g. PHP curl's default `CURLOPT_FOLLOWLOCATION=true`, `file_get_contents`, `fetch()` default in Node.js), the session cookie will land on your backend instead of the user's browser, and the user will not be logged in.

  Disable redirect following (`CURLOPT_FOLLOWLOCATION=false`, `redirect: 'manual'`) and forward the `Location` header explicitly.
</Warning>

### Error responses

**Gateway errors** — returned as JSON before the request reaches the LMS:

| Scenario            | HTTP Status | Body                                                          |
| ------------------- | ----------- | ------------------------------------------------------------- |
| Missing `?token`    | `400`       | `{"error": "token is required"}`                              |
| Malformed JWT       | `400`       | `{"error": "invalid token format"}`                           |
| Missing `iss` claim | `400`       | `{"error": "missing issuer (iss) claim"}`                     |
| Unknown issuer      | `401`       | `{"error": "unknown issuer: <iss>"}`                          |
| IP not whitelisted  | `403`       | `{"error": "IP x.x.x.x is not whitelisted for issuer <iss>"}` |

**LMS errors** — the LMS responds with a redirect (HTTP 307 in most cases) to its sign-in page with an `error` and `reason` query parameter. The `reason` value is verbose and reflects the underlying validation failure verbatim — useful for logging on your side. Common cases:

| Scenario                             | `reason=` (URL-decoded)                                                |
| ------------------------------------ | ---------------------------------------------------------------------- |
| Missing `?token` query               | `missing_token` (returned as `error=missing_token`, no `reason` param) |
| Malformed JWT                        | `Failed to base64url decode the payload`                               |
| Missing `iss` claim                  | `Missing issuer (iss) claim.`                                          |
| Unknown issuer                       | `Unsupported SSO issuer: <iss>`                                        |
| No public key configured for issuer  | `No public key or JWKS URL configured for SSO issuer: <iss>.`          |
| Invalid JWT signature                | `signature verification failed`                                        |
| Expired token (`exp`)                | `"exp" claim timestamp check failed`                                   |
| `aud` claim mismatch                 | `unexpected "aud" claim value`                                         |
| Token lifetime > 5 minutes           | `Token lifetime exceeds the allowed 5-minute window.`                  |
| Missing `email` claim                | `missing_email`                                                        |
| Missing `membershipId` claim         | `missing_membership_id`                                                |
| Member not active in partner records | `member_inactive`                                                      |
| Account creation failed              | `account_creation_failed`                                              |
| Session creation failed              | `session_creation_failed`                                              |

## Implementation Examples

### PHP

<CodeGroup>
  ```php Generate RSA Keys theme={null}
  <?php
  // Generate a new RSA key pair (run once)
  $config = [
      'private_key_bits' => 2048,
      'private_key_type' => OPENSSL_KEYTYPE_RSA,
  ];

  $keyPair = openssl_pkey_new($config);

  // Export private key (keep this secret!)
  openssl_pkey_export($keyPair, $privateKey);
  file_put_contents('private_key.pem', $privateKey);

  // Export public key (share with Terang AI)
  $publicKey = openssl_pkey_get_details($keyPair)['key'];
  file_put_contents('public_key.pem', $publicKey);

  echo "Keys generated successfully.\n";
  echo "Share public_key.pem with the Terang AI team.\n";
  ```

  <Info>
    **Understanding `jti` (JWT ID)**: It is highly recommended to use your own internal session hash (like an MD5 `signon` hash generated when the user logs into your system) as the `jti` value. This maps the user's Terang LMS session directly to your authentication event, making it secure and preventing replay attacks.
  </Info>

  ```php Sign & Proxy to Gateway theme={null}
  <?php
  // Install: composer require firebase/php-jwt
  use Firebase\JWT\JWT;

  $privateKey = file_get_contents('/path/to/private_key.pem');
  $now = time();

  $payload = [
      'iss'          => 'your-domain.com',  // Your domain
      'aud'          => 'terang.ai',
      'sub'          => 'member',          // 'member' | 'umum' | 'mahasiswa'
      'email'        => $member->email,
      'name'         => $member->name,
      'membershipId' => $member->id,
      'iat'          => $now,
      'exp'          => $now + 300,         // 5 minutes
      'jti'          => $member->signon_md5,
  ];

  $token = JWT::encode($payload, $privateKey, 'RS256', 'key-1');

  // Call Terang AI gateway from your backend (so api.terang.ai sees your whitelisted IP)
  $ch = curl_init('https://api.terang.ai/sso/verify?token=' . urlencode($token));
  curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);  // CRITICAL: do NOT follow redirects
  curl_setopt($ch, CURLOPT_HEADER, true);
  curl_setopt($ch, CURLOPT_NOBODY, true);
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  curl_setopt($ch, CURLOPT_TIMEOUT, 10);
  $response = curl_exec($ch);
  $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  curl_close($ch);

  // Gateway should return 302 with a Location header pointing to the LMS
  if ($httpCode === 302 && preg_match('/^Location:\s*(.+)$/mi', $response, $m)) {
      // Forward the user's browser to the LMS — the session cookie will be
      // set on the browser as it follows the rest of the redirect chain.
      header('Location: ' . trim($m[1]));
      exit;
  }

  // Surface gateway errors (400 / 401 / 403 / etc.)
  http_response_code($httpCode ?: 500);
  echo "SSO gateway returned unexpected status: $httpCode";
  exit;
  ```

  ```php JWKS Endpoint theme={null}
  <?php
  // Serve this at: https://your-domain.com/.well-known/jwks.json
  $publicKey = file_get_contents('/path/to/public_key.pem');
  $keyData = openssl_pkey_get_details(openssl_pkey_get_public($publicKey));

  $jwks = [
      'keys' => [
          [
              'kty' => 'RSA',
              'kid' => 'key-1',
              'use' => 'sig',
              'alg' => 'RS256',
              'n' => rtrim(strtr(base64_encode($keyData['rsa']['n']), '+/', '-_'), '='),
              'e' => rtrim(strtr(base64_encode($keyData['rsa']['e']), '+/', '-_'), '='),
          ],
      ],
  ];

  header('Content-Type: application/json');
  echo json_encode($jwks);
  ```
</CodeGroup>

### Node.js

<CodeGroup>
  ```javascript Generate RSA Keys theme={null}
  import { generateKeyPairSync } from 'crypto';
  import fs from 'fs';

  const { publicKey, privateKey } = generateKeyPairSync('rsa', {
    modulusLength: 2048,
    publicKeyEncoding: { type: 'spki', format: 'pem' },
    privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
  });

  fs.writeFileSync('private_key.pem', privateKey);
  fs.writeFileSync('public_key.pem', publicKey);

  console.log('Keys generated. Share public_key.pem with the Terang AI team.');
  ```

  ```javascript Sign & Proxy to Gateway theme={null}
  // Express handler — wire this to whatever route triggers the SSO hand-off
  import jwt from 'jsonwebtoken';
  import fs from 'fs';

  const privateKey = fs.readFileSync('./private_key.pem', 'utf8');

  app.get('/open-terang', async (req, res) => {
    const member = req.user; // from your own session

    const token = jwt.sign(
      {
        iss: 'your-domain.com',
        aud: 'terang.ai',
        sub: 'member', // 'member' | 'umum' | 'mahasiswa'
        email: member.email,
        name: member.name,
        membershipId: member.id,
        jti: member.signon_md5,
      },
      privateKey,
      {
        algorithm: 'RS256',
        expiresIn: '5m',
        keyid: 'key-1', // Must match the kid in your JWKS
      }
    );

    // Call the gateway from your backend so api.terang.ai sees your whitelisted IP.
    const gatewayResp = await fetch(
      `https://api.terang.ai/sso/verify?token=${encodeURIComponent(token)}`,
      { method: 'GET', redirect: 'manual' }  // CRITICAL: do NOT follow redirects
    );

    if (gatewayResp.status !== 302) {
      return res
        .status(gatewayResp.status)
        .send(`SSO gateway returned unexpected status: ${gatewayResp.status}`);
    }

    const location = gatewayResp.headers.get('location');

    // Forward the user's browser to the LMS — the session cookie will be set
    // on the browser as it follows the rest of the redirect chain.
    return res.redirect(302, location);
  });
  ```

  ```javascript JWKS Endpoint (Express) theme={null}
  import express from 'express';
  import { createPublicKey } from 'crypto';
  import fs from 'fs';

  const app = express();

  app.get('/.well-known/jwks.json', (req, res) => {
    const publicKey = createPublicKey(
      fs.readFileSync('./public_key.pem', 'utf8')
    );

    const jwk = publicKey.export({ format: 'jwk' });

    res.json({
      keys: [
        {
          kty: jwk.kty,
          kid: 'key-1',
          use: 'sig',
          alg: 'RS256',
          n: jwk.n,
          e: jwk.e,
        },
      ],
    });
  });

  app.listen(3000);
  ```
</CodeGroup>

## API Requests

All API calls go through `api.terang.ai` with a Bearer JWT token in the Authorization header.

```mermaid theme={null}
sequenceDiagram
    participant Client as Your Backend
    participant API as api.terang.ai
    participant LMS as Terang AI LMS

    Client->>API: Request with Authorization: Bearer JWT
    API->>API: Identify your organization from JWT
    API->>LMS: Forward request
    LMS->>API: Response
    API->>Client: Response
```

## Testing

You can decode and inspect your JWT at [jwt.io](https://jwt.io) before sending it to Terang AI.

Checklist:

* [ ] Your domain (`iss`) is registered with Terang AI (required for gateway routing)
* [ ] Your server IP address(es) are whitelisted (required for gateway IP check)
* [ ] Public key is shared with Terang AI (via JWKS URL or PEM file, used by the LMS for verification)
* [ ] JWT header uses `RS256` algorithm
* [ ] JWT `kid` header is set (and matches the `kid` in your JWKS if using Option 1)
* [ ] All required payload fields are present
* [ ] `aud` is set to `terang.ai`
* [ ] `exp` is within 5 minutes of `iat`
* [ ] `jti` is unique per request
* [ ] The call to `api.terang.ai/sso/verify` is made from your **backend**, not the user's browser
* [ ] Redirect following is **disabled** (`CURLOPT_FOLLOWLOCATION=false` / `redirect: 'manual'`)
* [ ] The `Location` header returned by the gateway is forwarded to the user's browser as a 302

## Key Rotation Guide

Rotating your keys regularly is a security best practice. The process differs depending on which option you chose for public key exchange.

### Rotating with JWKS (Option 1)

With JWKS, you can rotate keys with **zero downtime** — no coordination with Terang AI needed.

```mermaid theme={null}
sequenceDiagram
    participant You as Your Platform
    participant JWKS as your-domain.com/.well-known/jwks.json
    participant API as api.terang.ai

    Note over You: Step 1: Generate new key pair
    You->>JWKS: Add new key (kid=key-2), keep old key (kid=key-1)
    Note over JWKS: Both keys are now in JWKS
    Note over You: Step 2: Start signing with new key
    You->>API: JWT signed with kid=key-2
    API->>JWKS: Fetch JWKS (cached up to 1 hour)
    API->>API: Verify with key-2
    Note over You: Step 3: Wait for old tokens to expire (5 min)
    You->>JWKS: Remove old key (kid=key-1)
    Note over JWKS: Only key-2 remains
```

<Steps>
  <Step title="Generate a new RSA key pair">
    Create a new key pair with a **new `kid`** (e.g. `key-2`).

    <CodeGroup>
      ```bash OpenSSL theme={null}
      openssl genpkey -algorithm RSA -out new_private_key.pem -pkeyopt rsa_keygen_bits:2048
      openssl rsa -in new_private_key.pem -pubout -out new_public_key.pem
      ```

      ```php PHP theme={null}
      $config = ['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA];
      $keyPair = openssl_pkey_new($config);
      openssl_pkey_export($keyPair, $privateKey);
      file_put_contents('new_private_key.pem', $privateKey);
      $publicKey = openssl_pkey_get_details($keyPair)['key'];
      file_put_contents('new_public_key.pem', $publicKey);
      ```

      ```javascript Node.js theme={null}
      import { generateKeyPairSync } from 'crypto';
      import fs from 'fs';

      const { publicKey, privateKey } = generateKeyPairSync('rsa', {
        modulusLength: 2048,
        publicKeyEncoding: { type: 'spki', format: 'pem' },
        privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
      });

      fs.writeFileSync('new_private_key.pem', privateKey);
      fs.writeFileSync('new_public_key.pem', publicKey);
      ```
    </CodeGroup>
  </Step>

  <Step title="Add the new key to your JWKS endpoint">
    Your JWKS should now return **both** the old and new keys:

    ```json theme={null}
    {
      "keys": [
        { "kty": "RSA", "kid": "key-1", "use": "sig", "alg": "RS256", "n": "old-key-n...", "e": "AQAB" },
        { "kty": "RSA", "kid": "key-2", "use": "sig", "alg": "RS256", "n": "new-key-n...", "e": "AQAB" }
      ]
    }
    ```
  </Step>

  <Step title="Start signing JWTs with the new key">
    Update your JWT signing code to use the new private key and set `kid` to `key-2`.
  </Step>

  <Step title="Wait, then remove the old key">
    Wait at least **5 minutes** (max JWT lifetime) for all old tokens to expire. Then remove `key-1` from your JWKS endpoint.

    <Note>
      Terang AI caches JWKS responses for up to **1 hour**. If you need the new key to be picked up immediately, contact us.
    </Note>
  </Step>
</Steps>

### Rotating with Static PEM (Option 2)

With a static key, rotation requires coordination with the Terang AI team.

<Steps>
  <Step title="Generate a new key pair">
    Same as above — create a new RSA key pair.
  </Step>

  <Step title="Send the new public key to Terang AI">
    Email the new `public_key.pem` to [founders@terang.ai](mailto:founders@terang.ai). **Do not start using the new key yet.**
  </Step>

  <Step title="Wait for confirmation">
    We will update our configuration and confirm when the new key is active.
  </Step>

  <Step title="Switch to the new key">
    Once confirmed, update your signing code to use the new private key.
  </Step>
</Steps>

<Warning>
  If you switch to the new private key **before** we update the public key, all JWT verification will fail and your members will not be able to log in.
</Warning>
