JWT single sign-on
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
- A visitor opens
/account/login/sso/{name}on Raytha. Admins use/raytha/login/sso/{name}. - Raytha redirects to the scheme's Sign in URL and adds a
raytha_callback_urlquery parameter. Raytha keeps any query string you already have on that URL. - Your app signs the person in however it normally does.
- Your app creates a JWT signed with the shared secret and redirects the browser to
raytha_callback_urlwithtoken=…added. - 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
- Open Settings > Authentication and create a JWT scheme. Use a short developer name, such as
my_app. - Set Sign in URL to the page in your app that starts the flow.
- Set JWT secret to at least 32 random ASCII characters. Generate one with
openssl rand -hex 32. - Turn on Enabled for users, Enabled for admins, or both. Admins must already have an account with the same email address. See the overview.
- 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
| Claim | Required | Meaning |
|---|---|---|
email | Yes | The person's email address. It must be valid. Raytha lowercases it. |
exp | Yes | Expiry 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. |
sub | Recommended | A 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. |
jti | With high security | A unique id for this token. See below. |
given_name, family_name | No | First and last name. When present, Raytha overwrites the stored names on every sign-in. |
groups | No | An 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: noneare 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
nbfis 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
jtifor 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
expshort 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.
| Message | Cause 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 token | Add the email claim. |
'email' is not a valid email address | Send 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 disabled | Both 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 page | The 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
expto a couple of minutes and turn on high security. - Make
subpermanent. 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.AuthenticationSchemesin a Raytha Function has the scheme's label, URLs and toggles, notJwtSecretKey. - 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
- Authentication and single sign-on overview: account matching, groups and URLs.
- SAML single sign-on: use an identity provider instead.
- Running behind a proxy: make sure the Website URL and client addresses are right.