Cruma
DashboardDownload

Verify who signed in

Behind an OAuth2 middleware, including Sign in with Cruma, every signed-in request carries an x-cruma-identity header: a signed token your app can verify with Cruma's public key. A verified token proves the agent admitted this visitor for your site, even if your app can also be reached some other way. See What your app receives after sign-in for the plain x-auth-* headers.

The token

A JWT signed with EdDSA (Ed25519). The header carries alg and a kid naming the key that signed it.

ClaimMeaning
issAlways cruma-agent.
audThe hostname the request was made to, lower case, no port. Or the value you set; see Choosing the audience below.
subThe user id; the same value as x-auth-subject.
providerWhich sign-in they used. Left out when unknown.
emailThe visitor's email. Left out when the sign-in gives none.
email_verifiedtrue only when the sign-in provider or Cruma verified the email.
nameDisplay name. Left out when unknown.
orgThe Cruma organization the visitor signed in as. Left out when there is none.
iat, expWhen the token was minted and when it stops being valid.
jtiA unique id for this token.

email_verified is the only email claim to trust. Use email for sign-in or access decisions only when email_verified is true.

A token lives for at most 60 seconds and is renewed on later requests. Your verifier must check the signature, exp, iss and aud, and accept only EdDSA.

Getting the public key

Use either of these:

  • The key set. Every signed-in hostname serves its keys at /_cruma_auth/jwks.json, as a standard JWK Set matched by kid. Libraries that fetch and cache a JWKS need nothing else.
  • A pinned key. Copy the key once and keep it in your app's configuration. Run cruma auth identity-key (--format pem, jwks or json), read it in the desktop app under Settings > Identity key, or ask a coding agent for it over MCP with get_identity_key.

After a key rotation the previous key stays published, and listed by the commands above, for ten minutes. An app with a pinned key can add the new key beside the old one, then drop the old one, without signing anyone out.

Choosing the audience

By default aud is the hostname, so a token minted for one site is refused by another. To use a different value, such as a service name, set identity.audience on the oauth2 middleware; your app then requires that value. The value is used exactly as written, at most 253 bytes, with no spaces.

Stopping the plain headers

By default x-auth-subject, x-auth-provider and x-auth-claims are sent next to the token. Once your app verifies the token, set identity.send_auth_headers to false to stop sending them. The agent still removes any copies a visitor sends, and x-cruma-identity is always sent.

frontends:
  - hostname: "app"
    backend_id: web
    middlewares:
      - type: oauth2
        provider_ids: ["cruma"]
        session_secret: "replace-with-a-random-secret"
        identity:
          audience: "orders-api"          # default: the hostname
          send_auth_headers: false        # default: true

Both options are read from the configuration file and can also be set by a coding agent over MCP. The desktop app keeps them when you edit the middleware's other settings. Leave identity out for the defaults.

Example: ASP.NET Core

AddJwtBearer reads the token from x-cruma-identity and checks exp, iss and aud. The .NET token libraries have no EdDSA support, so the signature check uses the BouncyCastle package. Replace <kid> and <x> with the values from cruma auth identity-key --format json.

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Signers;

const string Site = "app.example.com";

var trustedKeys = new Dictionary<string, Ed25519PublicKeyParameters>
{
    ["<kid>"] = new(Base64UrlEncoder.DecodeBytes("<x>"), 0),
};

var builder = WebApplication.CreateBuilder(args);
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.MapInboundClaims = false;
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                context.Token = context.Request.Headers["X-Cruma-Identity"];
                return Task.CompletedTask;
            },
        };
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidIssuer = "cruma-agent",
            ValidAudience = Site,
            ValidateLifetime = true,
            RequireExpirationTime = true,
            ClockSkew = TimeSpan.FromSeconds(5),
            SignatureValidator = (token, _) =>
            {
                var jwt = new JsonWebToken(token);
                if (jwt.Alg != "EdDSA" || jwt.Kid is null || !trustedKeys.TryGetValue(jwt.Kid, out var key))
                    throw new SecurityTokenInvalidSignatureException("Unknown key or algorithm");
                var signed = System.Text.Encoding.ASCII.GetBytes($"{jwt.EncodedHeader}.{jwt.EncodedPayload}");
                var signature = Base64UrlEncoder.DecodeBytes(jwt.EncodedSignature);
                var verifier = new Ed25519Signer();
                verifier.Init(false, key);
                verifier.BlockUpdate(signed, 0, signed.Length);
                if (!verifier.VerifySignature(signature))
                    throw new SecurityTokenInvalidSignatureException("Bad signature");
                return jwt;
            },
        };
    });
builder.Services.AddAuthorization();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/", (HttpContext http) => $"Hello {http.User.FindFirst("name")?.Value ?? http.User.FindFirst("sub")?.Value}")
    .RequireAuthorization();
app.Run();

Example: Node.js

With jose, which fetches and caches the key set for you:

import express from "express";
import { createRemoteJWKSet, jwtVerify } from "jose";

const SITE = "app.example.com";
const keys = createRemoteJWKSet(new URL(`https://${SITE}/_cruma_auth/jwks.json`));

async function requireSignedIn(req, res, next) {
  const token = req.get("x-cruma-identity");
  if (!token) return res.status(401).send("Not signed in");
  try {
    const { payload } = await jwtVerify(token, keys, {
      algorithms: ["EdDSA"],
      issuer: "cruma-agent",
      audience: SITE,
      requiredClaims: ["exp", "sub"],
    });
    req.user = payload;
    next();
  } catch {
    res.status(401).send("Invalid identity");
  }
}

const app = express();
app.get("/", requireSignedIn, (req, res) => {
  const email = req.user.email_verified ? req.user.email : undefined;
  res.send(`Hello ${req.user.name ?? req.user.sub}${email ? ` <${email}>` : ""}`);
});
app.listen(3000);

To pin the key instead, pass await importSPKI(pem, "EdDSA") from cruma auth identity-key --format pem to jwtVerify in place of keys.

Sign-in types without a token

Only the oauth2 sign-in carries x-cruma-identity. HTTP Basic auth, a bearer token accepted by the agent's own token validator and the form login send neither the token nor the x-auth-* headers, so a route that requires the token needs an oauth2 middleware.