TryMellon
Navigation

Verify JWT offline (zero round-trip)

Validate tryMellon session tokens locally with JWKS — no API call per request.

Verify JWT offline

tryMellon session tokens are RS256 JWTs signed with rotating keys published at /.well-known/jwks.json. Validating them locally avoids one network round-trip per authenticated request.

When to use offline vs introspection

Offline (JWKS)Introspection (POST /oauth/introspect)
Latency~0ms (after first JWKS fetch + cache)~50ms per call
Detects revocation❌ until token expires✅ live check
Use whenHigh-throughput APIs, every request authenticatedSensitive operations (payment, role change)

Recommended pattern: offline on every request, introspection on sensitive operations, plus session.revoked webhook to invalidate your own session cache (see Handling revocation).

JWKS endpoints

EndpointCache
GET https://api.trymellonauth.com/.well-known/jwks.json1h (Cache-Control: max-age=3600)
GET https://api.trymellonauth.com/.well-known/openid-configuration1h

Node.js (jose)

import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(
  new URL('https://api.trymellonauth.com/.well-known/jwks.json'),
);

export async function verifySession(token: string) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'https://api.trymellonauth.com',
    audience: process.env.TRYMELLON_CLIENT_ID,
  });
  return payload; // { sub, iat, exp, ... }
}

createRemoteJWKSet caches keys in-process and refreshes when an unknown kid is seen — handles rotation transparently.

Go (lestrrat-go/jwx)

import (
    "context"
    "github.com/lestrrat-go/jwx/v2/jwk"
    "github.com/lestrrat-go/jwx/v2/jwt"
)

var jwksCache *jwk.Cache

func init() {
    ctx := context.Background()
    jwksCache = jwk.NewCache(ctx)
    _ = jwksCache.Register("https://api.trymellonauth.com/.well-known/jwks.json")
}

func VerifySession(ctx context.Context, token string) (jwt.Token, error) {
    keys, err := jwksCache.Get(ctx, "https://api.trymellonauth.com/.well-known/jwks.json")
    if err != nil {
        return nil, err
    }
    return jwt.Parse([]byte(token),
        jwt.WithKeySet(keys),
        jwt.WithIssuer("https://api.trymellonauth.com"),
        jwt.WithAudience(os.Getenv("TRYMELLON_CLIENT_ID")),
    )
}

Python (python-jose)

from functools import lru_cache
import httpx
from jose import jwt

JWKS_URL = "https://api.trymellonauth.com/.well-known/jwks.json"

@lru_cache(maxsize=1)
def _jwks() -> dict:
    return httpx.get(JWKS_URL, timeout=5).json()

def verify_session(token: str, audience: str) -> dict:
    return jwt.decode(
        token,
        _jwks(),
        algorithms=["RS256"],
        audience=audience,
        issuer="https://api.trymellonauth.com",
    )

For production, replace lru_cache with a TTL cache (e.g. cachetools.TTLCache(maxsize=1, ttl=3600)) and refresh on JWTError due to unknown kid.

tryMellon SDK

Available in @trymellon/js v3.4+. Zero runtime dependencies — validation runs on native WebCrypto, same SDK both browser and Node.

import { TryMellon } from '@trymellon/js';

const created = TryMellon.create({
  appId: process.env.TRYMELLON_APP_ID!,
  publishableKey: process.env.TRYMELLON_PUBLISHABLE_KEY!,
});
if (!created.ok) throw created.error;
const client = created.value;

const result = await client.session.verifyOffline(sessionToken);
if (!result.ok) {
  // result.error.code ∈ { JWT_KID_MISMATCH, SESSION_EXPIRED, INVALID_ARGUMENT, NETWORK_FAILURE }
  throw result.error;
}
const { userId, tenantId, appId, customClaims } = result.value;

Behaviour notes:

  • JWKS cached for 1 hour (module-level singleton, matches server Cache-Control: max-age=3600).
  • exp check applies ±30s clock skew — compensates for client clock drift without widening the replay window materially. iat is strict (future-dated tokens rejected).
  • userId falls back to the JWT sub claim when the token was issued without an explicit user_id (service-to-service tokens). Regular user sessions always carry user_id, so this fallback is transparent.
  • Algorithm hard-locked to RS256 with triple-check (header.alg, jwk.kty, jwk.alg) — defense against algorithm-confusion.
  • Full design rationale in ADR-SDK-003 (backend repo docs/arquitectura/ADRs).

Troubleshooting

ErrorCauseFix
JWT_KID_MISMATCHSigning key rotated since last cache refresh.Re-fetch JWKS — jose and jwx do this automatically. Custom code must invalidate cache on unknown kid.
audience invalidToken issued for a different client_id.Confirm aud claim matches your app’s client_id.
signature verification failedWrong issuer or token from another tenant.Confirm iss and that the token came from your application.