DylogDocs

JWTs for backends

The frozen claim contract, how to obtain a token, and verifiers for Node, Spring and Go.

Backends never see cookies. They receive a short-lived RS256 JWT and verify it locally.

Claim contract (frozen)

// header: { "alg": "RS256", "kid": "<key id>", "typ": "JWT" }
{
  "sub":      "<user id> | apikey:<key id> for organization API keys",
  "email":    "owner@ssd.dylog.ai",             // null for organization API keys
  "name":     "SSD Owner",
  "org_id":   "<organization id> | null",
  "org_slug": "ssd | null",                     // URL label, NOT the tenant key
  "org_role": "owner | admin | member | service | null",
  "org_meta": { "client-code": "SSD_JKLMNBVCXZA" } | null,
  "is_staff": false,
  "iss":      "https://accounts.dylog.ai",      // the issuing environment's URL
  "aud":      "dylog-services",
  "exp":      1789714856,                       // iat + 15 minutes
  "iat":      1789713956,
  "jti":      "<uuid>"
}

Rules:

  • iss and aud are validated, not informational. Each environment is its own issuer.
  • org_meta["client-code"] is the tenant key for data queries. org_slug is only for URLs.
  • is_staff: true with all org_* null is legal: staff acting outside any tenant.
  • Claims are additive-only. New claims may appear; existing ones never change meaning.
  • Verifiers pin algorithms: ["RS256"] and allow at most 5 seconds of clock skew.
  • Clients refresh at exp - 60s. Tokens are not revocable before expiry; that is why they last 15 minutes.

Getting a token

CallerRequest
Browser, active organizationGET /api/auth/token with the cookie
Browser, explicit organizationGET /api/token?org=<slug> with the cookie
Service, organization API keyGET /api/token with x-api-key: dylog_org_…
Service, personal API keyGET /api/token?org=<slug> with x-api-key: dylog_…

Prefer the explicit form. It does not depend on the session's mutable active organization and it is the only form organization keys can use. The response is { "token": "<jwt>" }.

Verifying

Node, Workers, Hono (jose)

import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(new URL("https://accounts.dylog.ai/api/auth/jwks"));

export async function verify(token: string) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: "https://accounts.dylog.ai",
    audience: "dylog-services",
    algorithms: ["RS256"],
    clockTolerance: 5,
  });
  return {
    userId: payload.sub!,
    clientCode: (payload.org_meta as { "client-code"?: string } | null)?.["client-code"] ?? null,
    role: payload.org_role as string | null,
    isStaff: payload.is_staff === true,
  };
}

Spring Security (resource server)

There is no OIDC discovery document, so point Spring at the JWKS URL directly:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: https://accounts.dylog.ai/api/auth/jwks
          jws-algorithms: RS256
@Bean
JwtDecoder jwtDecoder(@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwks) {
  NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwks).jwsAlgorithm(SignatureAlgorithm.RS256).build();
  OAuth2TokenValidator<Jwt> issuer = JwtValidators.createDefaultWithIssuer("https://accounts.dylog.ai");
  OAuth2TokenValidator<Jwt> audience = new JwtClaimValidator<List<String>>("aud", aud -> aud != null && aud.contains("dylog-services"));
  decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(issuer, audience));
  return decoder;
}

Read the tenant with jwt.getClaimAsMap("org_meta").get("client-code"). During a migration accept a list of issuers (old and new) and tighten it afterwards.

Go (lestrrat-go/jwx)

cache := jwk.NewCache(ctx)
cache.Register("https://accounts.dylog.ai/api/auth/jwks")
set, _ := cache.Get(ctx, "https://accounts.dylog.ai/api/auth/jwks")

tok, err := jwt.Parse([]byte(raw),
  jwt.WithKeySet(set),
  jwt.WithIssuer("https://accounts.dylog.ai"),
  jwt.WithAudience("dylog-services"),
  jwt.WithAcceptableSkew(5*time.Second),
)
meta, _ := tok.Get("org_meta")
clientCode := meta.(map[string]any)["client-code"]

Key rotation

Public keys are served at /api/auth/jwks with a kid. Cache them by kid and refetch on an unknown kid. Private keys are stored encrypted in the accounts database; rotating BETTER_AUTH_SECRET without a versioned secret list would invalidate them, so that is an operations task, not a routine one.

On this page