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.
-
Signature — ES256, against the key whose
kidmatches the token header. -
iss— must equalhttps://evonia-gate.annatarhe.com/api/authexactly. Note the/api/authsuffix; the issuer is not the bare origin. -
aud— must be your API's identifier. Reject tokens minted for a sibling service. -
expandnbf— with at most a few seconds of clock skew. -
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}`);
}
}package gateauth
import (
"context"
"fmt"
"github.com/coreos/go-oidc/v3/oidc"
)
const (
issuer = "https://evonia-gate.annatarhe.com/api/auth"
audience = "https://api.shelltime.xyz"
)
type Claims struct {
Scope string `json:"scope"`
OrganizationID string `json:"https://evonia-gate.annatarhe.com/organization_id"`
ProjectID string `json:"https://evonia-gate.annatarhe.com/project_id"`
EnvironmentID string `json:"https://evonia-gate.annatarhe.com/environment_id"`
}
// Build once at startup: the provider caches JWKS and refreshes on unknown kid.
func NewVerifier(ctx context.Context) (*oidc.IDTokenVerifier, error) {
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, fmt.Errorf("gate discovery: %w", err)
}
return provider.Verifier(&oidc.Config{
ClientID: audience, // checked against `aud`
SupportedSigningAlgs: []string{oidc.ES256},
}), nil
}
func Verify(ctx context.Context, v *oidc.IDTokenVerifier, raw string) (*Claims, error) {
token, err := v.Verify(ctx, raw)
if err != nil {
return nil, fmt.Errorf("invalid gate token: %w", err)
}
var claims Claims
if err := token.Claims(&claims); err != nil {
return nil, err
}
return &claims, nil
}import JOSESwift
struct GateVerifier {
static let issuer = "https://evonia-gate.annatarhe.com/api/auth"
static let audience = "https://api.rawback.app"
let keys: JWKSCache // caches /.well-known/jwks.json, refetches on unknown kid
func verify(_ raw: String) async throws -> GateClaims {
let jws = try JWS(compactSerialization: raw)
guard let kid = jws.header.kid else { throw GateError.missingKeyID }
let key = try await keys.publicKey(kid: kid)
guard let verifier = Verifier(signatureAlgorithm: .ES256, key: key),
let payload = try? jws.validate(using: verifier).payload
else { throw GateError.badSignature }
let claims = try JSONDecoder().decode(GateClaims.self, from: payload.data())
guard claims.iss == Self.issuer else { throw GateError.wrongIssuer }
guard claims.aud.contains(Self.audience) else { throw GateError.wrongAudience }
guard claims.exp > Date().timeIntervalSince1970 - 5 else { throw GateError.expired }
return claims
}
}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 -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
| Claim | Meaning |
|---|---|
iss | Always https://evonia-gate.annatarhe.com/api/auth |
sub | The user id, or the client id for machine-to-machine tokens |
aud | The resource requested at authorization or token time |
exp | 600 s after issue for user tokens, 300 s for machine-to-machine |
scope | Space-separated granted scopes |
https://evonia-gate.annatarhe.com/organization_id | Organization the client belongs to |
https://evonia-gate.annatarhe.com/project_id | Project from the client's metadata |
https://evonia-gate.annatarhe.com/environment_id | Environment, when the client is pinned to one |
https://evonia-gate.annatarhe.com/resource | The requested resource |
Claims are namespaced by URL to avoid colliding with standard ones. They are advertised in
claims_supported in the discovery document.