
# 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](https://cruma.io/book/cruma-tunnels/sign-in-headers.md) 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 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.

```yaml
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`.

```csharp
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`](https://github.com/panva/jose), which fetches and caches the key set for you:

```js
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.

---

- Book: [Cruma Tunnels](https://cruma.io/book/cruma-tunnels.md)
- Previous: [What your app receives after sign-in](https://cruma.io/book/cruma-tunnels/sign-in-headers.md)
- Pages: [Home](https://cruma.io/home.md) · [Docs](https://cruma.io/docs.md) · [Pricing](https://cruma.io/pricing.md) · [Share localhost](https://cruma.io/share-localhost.md) · [Webhook testing](https://cruma.io/webhook-testing.md) · [Homelab](https://cruma.io/homelab.md) · [AI coding agents](https://cruma.io/ai-coding-agents.md) · [Cruma vs ngrok](https://cruma.io/ngrok-alternative.md) · [Cruma vs Cloudflare Tunnel](https://cruma.io/cloudflare-tunnel-alternative.md) · [Blog](https://cruma.io/blog.md)
