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.
| Claim | Meaning |
|---|---|
iss | Always cruma-agent. |
aud | The hostname the request was made to, lower case, no port. Or the value you set; see Choosing the audience below. |
sub | The user id; the same value as x-auth-subject. |
provider | Which sign-in they used. Left out when unknown. |
email | The visitor's email. Left out when the sign-in gives none. |
email_verified | true only when the sign-in provider or Cruma verified the email. |
name | Display name. Left out when unknown. |
org | The Cruma organization the visitor signed in as. Left out when there is none. |
iat, exp | When the token was minted and when it stops being valid. |
jti | A 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 bykid. 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,jwksorjson), read it in the desktop app under Settings > Identity key, or ask a coding agent for it over MCP withget_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.