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:
issandaudare validated, not informational. Each environment is its own issuer.org_meta["client-code"]is the tenant key for data queries.org_slugis only for URLs.is_staff: truewith allorg_*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
| Caller | Request |
|---|---|
| Browser, active organization | GET /api/auth/token with the cookie |
| Browser, explicit organization | GET /api/token?org=<slug> with the cookie |
| Service, organization API key | GET /api/token with x-api-key: dylog_org_… |
| Service, personal API key | GET /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.