Verifying tokens

Your API should verify Gate tokens locally against the published JSON Web Key Set. No network call to Gate is needed on the request path, so verification stays fast and your API keeps working during a Gate deployment.

What to check

Signature validity alone is not enough. A token signed by Gate but issued for a different audience is not a token for you.

  1. Signature — ES256, against the key whose kid matches the token header.

  2. iss — must equal https://evonia-gate.annatarhe.com/api/auth exactly. Note the /api/auth suffix; the issuer is not the bare origin.

  3. aud — must be your API's identifier. Reject tokens minted for a sibling service.

  4. exp and nbf — with at most a few seconds of clock skew.

  5. scope — the permission your endpoint actually requires. Do this last, per endpoint.

Verify

import { createRemoteJWKSet, jwtVerify } from "jose";

// Module scope: the set caches keys and refetches only when it sees an unknown `kid`.
const jwks = createRemoteJWKSet(new URL("https://evonia-gate.annatarhe.com/.well-known/jwks.json"));

const ISSUER = "https://evonia-gate.annatarhe.com/api/auth";
const AUDIENCE = "https://api.shelltime.xyz";

export async function verifyGateToken(token: string) {
  const { payload } = await jwtVerify(token, jwks, {
    algorithms: ["ES256"],
    audience: AUDIENCE,
    clockTolerance: 5,
    issuer: ISSUER,
  });

  return {
    subject: payload.sub,
    scopes: String(payload.scope ?? "")
      .split(" ")
      .filter(Boolean),
    organizationId: payload["https://evonia-gate.annatarhe.com/organization_id"] as
      string | undefined,
    projectId: payload["https://evonia-gate.annatarhe.com/project_id"] as string | undefined,
    environmentId: payload["https://evonia-gate.annatarhe.com/environment_id"] as
      string | undefined,
  };
}

export function requireScope(scopes: string[], required: string) {
  if (!scopes.includes(required)) {
    throw new HttpError(403, `Missing required scope: ${required}`);
  }
}

Key rotation

Gate signs with ES256 and rotates its key every 30 days, keeping the previous key valid for a further 30 days. That grace period means you never need a coordinated rollout — but your JWKS cache must respect it.

Use a library that caches JWKS and refetches when it encounters an unknown kid. Both createRemoteJWKSet and go-oidc do this. A cache that refreshes on a fixed timer instead will start rejecting valid tokens the moment a new key appears.

JWT versus opaque tokens

Gate issues opaque access tokens prefixed gate_at_ as well as JWTs. If your token does not parse as a JWT, do not try to — ask Gate about it instead:

curl
curl -sS -X POST https://evonia-gate.annatarhe.com/api/auth/oauth2/introspect \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d token="$ACCESS_TOKEN"

Introspection returns {"active": false} for anything unknown, expired, or revoked.

The /token endpoint is disabled

Gate sets disabledPaths: ["/token"], which turns off the better-auth JWT plugin's /api/auth/token endpoint. The OAuth token endpoint at /api/auth/oauth2/token is unaffected — that is the one you want.

Claims reference

ClaimMeaning
issAlways https://evonia-gate.annatarhe.com/api/auth
subThe user id, or the client id for machine-to-machine tokens
audThe resource requested at authorization or token time
exp600 s after issue for user tokens, 300 s for machine-to-machine
scopeSpace-separated granted scopes
https://evonia-gate.annatarhe.com/organization_idOrganization the client belongs to
https://evonia-gate.annatarhe.com/project_idProject from the client's metadata
https://evonia-gate.annatarhe.com/environment_idEnvironment, when the client is pinned to one
https://evonia-gate.annatarhe.com/resourceThe requested resource

Claims are namespaced by URL to avoid colliding with standard ones. They are advertised in claims_supported in the discovery document.