Platform CLI
Learn
Developer docs User guide Quickstart CLI and AI agents Blog
Company
Services About Contact Links Get started

JWT single sign-on

Updated

JWT single sign-on lets your own site or app be the login for Raytha. A person signs in to your app, your app redirects them to Raytha with a short-lived signed token, and Raytha signs them in. You build one page and share one secret.

How it works

  1. A visitor opens /account/login/sso/{name} on Raytha. Admins use /raytha/login/sso/{name}.
  2. Raytha redirects to the scheme's Sign in URL and adds a raytha_callback_url query parameter. Raytha keeps any query string you already have on that URL.
  3. Your app signs the person in however it normally does.
  4. Your app creates a JWT signed with the shared secret and redirects the browser to raytha_callback_url with token=… added.
  5. Raytha checks the signature and expiry, finds or creates the account, sets the sign-in cookie and redirects to the page the visitor wanted.

Raytha never calls your app. All it needs is the token in the browser's redirect.

Set up the scheme

  1. Open Settings > Authentication and create a JWT scheme. Use a short developer name, such as my_app.
  2. Set Sign in URL to the page in your app that starts the flow.
  3. Set JWT secret to at least 32 random ASCII characters. Generate one with openssl rand -hex 32.
  4. Turn on Enabled for users, Enabled for admins, or both. Admins must already have an account with the same email address. See the overview.
  5. Store the same secret in your app, for example as RAYTHA_JWT_SECRET.

Try it with a token from the shell

Before you write any app code, sign a token yourself and send it to the callback. This script needs openssl and jq:

#!/usr/bin/env bash
# Usage: RAYTHA_JWT_SECRET=... ./raytha-token.sh [email protected] user-123
# For testing a scheme. The secret is visible to other processes while openssl runs.
set -euo pipefail

email="$1"
sub="$2"

b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }

header='{"alg":"HS256","typ":"JWT"}'
payload=$(jq -cn \
  --arg sub "$sub" \
  --arg email "$email" \
  --arg jti "$(openssl rand -hex 16)" \
  --argjson exp "$(($(date +%s) + 120))" \
  '{sub: $sub, email: $email, given_name: "Ada", family_name: "Lovelace", jti: $jti, exp: $exp}')

unsigned="$(printf '%s' "$header" | b64url).$(printf '%s' "$payload" | b64url)"
signature=$(printf '%s' "$unsigned" | openssl dgst -sha256 -hmac "$RAYTHA_JWT_SECRET" -binary | b64url)

echo "$unsigned.$signature"

Send the token to the public callback of your scheme:

TOKEN=$(RAYTHA_JWT_SECRET='your-secret' ./raytha-token.sh [email protected] user-123)
curl -si "https://example.com/account/login/jwt/my_app?token=$TOKEN" | head -n 12

On success the response is a redirect and sets the sign-in cookie: you see a 302 status and a Set-Cookie header. On failure the response is a 403 page. The default page says only You do not have authorization to access this resource, so see the Errors section below for how to read the reason. Use the public callback to debug even when you plan to use admin sign-in. The checks are the same.

Redirect from your app

Your sign-in page receives raytha_callback_url. Check that it points at your Raytha site before you redirect to it. Otherwise anyone can craft a link that makes your app send a signed token to a site they control.

Node.js

This version uses only node:crypto:

const crypto = require("node:crypto");

// The origin of your Raytha site, from its Website URL setting.
const RAYTHA_ORIGIN = "https://example.com";

function signToken(claims, secret) {
  const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
  const unsigned = `${encode({ alg: "HS256", typ: "JWT" })}.${encode(claims)}`;
  const signature = crypto.createHmac("sha256", secret).update(unsigned).digest("base64url");
  return `${unsigned}.${signature}`;
}

// callbackUrl is the raytha_callback_url query parameter your sign-in page received.
function raythaRedirectUrl(callbackUrl, user) {
  const url = new URL(callbackUrl);
  if (url.origin !== RAYTHA_ORIGIN) {
    throw new Error("raytha_callback_url is not on the Raytha site");
  }
  const token = signToken(
    {
      sub: user.id,
      email: user.email,
      given_name: user.firstName,
      family_name: user.lastName,
      jti: crypto.randomUUID(),
      exp: Math.floor(Date.now() / 1000) + 120,
    },
    process.env.RAYTHA_JWT_SECRET
  );
  url.searchParams.set("token", token); // keeps returnUrl and any other parameters
  return url.toString();
}

// In an Express route, after the person is signed in to your app:
// res.redirect(raythaRedirectUrl(req.query.raytha_callback_url, req.user));

If you already use a JWT library, such as jsonwebtoken, sign with HS256 and the same claims.

C#

// dotnet add package System.IdentityModel.Tokens.Jwt
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;

public static class RaythaSignIn
{
    // The origin of your Raytha site, from its Website URL setting.
    const string RaythaOrigin = "https://example.com";

    public static string RedirectUrl(string callbackUrl, string userId, string email, string firstName, string lastName)
    {
        var callback = new Uri(callbackUrl);
        if (callback.GetLeftPart(UriPartial.Authority) != RaythaOrigin)
        {
            throw new InvalidOperationException("raytha_callback_url is not on the Raytha site");
        }

        // Raytha reads the secret as ASCII and pads it with NUL bytes to 32 bytes.
        var secret = Environment.GetEnvironmentVariable("RAYTHA_JWT_SECRET")!;
        var key = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(secret.PadRight(32, '\0')));

        var token = new JwtSecurityToken(
            claims: new[]
            {
                new Claim(JwtRegisteredClaimNames.Sub, userId),
                new Claim(JwtRegisteredClaimNames.Email, email),
                new Claim(JwtRegisteredClaimNames.GivenName, firstName),
                new Claim(JwtRegisteredClaimNames.FamilyName, lastName),
                new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
            },
            expires: DateTime.UtcNow.AddMinutes(2),
            signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));

        var jwt = new JwtSecurityTokenHandler().WriteToken(token);

        // Keep the query string already on the callback URL (it can hold returnUrl).
        var separator = string.IsNullOrEmpty(callback.Query) ? "?" : "&";
        return callbackUrl + separator + "token=" + Uri.EscapeDataString(jwt);
    }
}

The PadRight(32, '\0') matters. Raytha pads secrets shorter than 32 characters with NUL bytes, and a .NET signer must do the same. Node, OpenSSL and most other libraries use the secret as is, so shorter secrets still verify there. A secret of 32 characters or more needs no padding anywhere.

Token reference

ClaimRequiredMeaning
emailYesThe person's email address. It must be valid. Raytha lowercases it.
expYesExpiry as seconds since 1970. A token without it is rejected as Invalid security token. Raytha allows no clock skew, so keep your server clock accurate. Use 2 minutes or less.
subRecommendedA stable, unique id for the person in your app. Raytha stores it and looks the account up by it first. Without it, matching falls back to the email address.
jtiWith high securityA unique id for this token. See below.
given_name, family_nameNoFirst and last name. When present, Raytha overwrites the stored names on every sign-in.
groupsNoAn array of user group developer names, for example ["editors","staff"]. A single string also works. See the overview for matching rules.

Other claims are ignored. Raytha does not check iss or aud. It also does not read a name from name.

Signing rules

  • Sign with HS256. A secret of 48 ASCII characters also allows HS384, and 64 allows HS512. Shorter secrets fail for those algorithms with Invalid security token.
  • RS256 and alg: none are not accepted.
  • Use ASCII characters only in the secret. Raytha encodes it as ASCII, so a secret with accented characters never matches what your library signs with. Hex and base64url strings are safe.
  • Raytha rejects a token whose nbf is in the future.

High security: one-time tokens

Turn on JWT high security to make each token usable once. Raytha then requires jti and remembers every one it accepts. A second sign-in with the same jti fails with Security token already consumed: {jti}. This limits what an attacker can do with a token copied from a browser history, a proxy log or a Referer header.

  • Generate a new random jti for every token. A UUID is fine.
  • Raytha keeps every accepted id and never deletes it, so a token can never reuse an id, not even weeks later.
  • Keep exp short anyway. The one-time check does not replace it.

Errors

A failed callback answers 403. Raytha hands the message below to your site's 403 error template as Target.ErrorMessage, but the default template does not print it. While you test, add {{ Target.ErrorMessage }} to the 403 error template in your theme, call the public callback, and remove the line when you are done.

MessageCause and fix
Invalid security token.The signature, structure or algorithm is wrong. Check the secret on both sides, that you sign with HS256, that exp is present, and that nbf is not in the future.
Security token has expired.exp is in the past. Check both clocks and that you add seconds, not milliseconds.
'email' is missing from security tokenAdd the email claim.
'email' is not a valid email addressSend a real address.
JWT high security enabled: 'jti' attribute is required in security token.Add a unique jti.
Security token already consumed: …The jti was used before. Generate a new token per sign-in.
Authentication scheme is disabledBoth Enabled for users and Enabled for admins are off.
Authentication scheme disabled for administrators. or …for public users.The matched account is an admin or a public user, and the scheme is not enabled for that kind. An unknown email on an admin-only scheme gets the public-users message, and no account is created.
User has been deactivated.An admin deactivated the account.
A not found pageThe developer name in the URL does not match a scheme.

Gotchas

  • Validate raytha_callback_url. Raytha builds it from your Website URL setting, but it travels through the browser. Compare its origin with your Raytha origin, as the examples do.
  • Tokens in URLs get logged. The token appears in the query string of the callback. Keep exp to a couple of minutes and turn on high security.
  • Make sub permanent. If it changes for a person, Raytha falls back to the email address, and a changed email creates a second account.
  • A new email creates a public user only when the scheme is enabled for users. An admin-only scheme refuses an unknown address and creates nothing.
  • Functions cannot read the secret. CurrentOrganization.AuthenticationSchemes in a Raytha Function has the scheme's label, URLs and toggles, not JwtSecretKey.
  • Changing the secret signs nobody out. It only affects tokens issued afterward. Existing sessions keep their cookie.
  • Behind a path base, the callback URL includes it, for example https://example.com/cms/account/login/jwt/my_app. Do not strip it.

Next steps