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 when | High-throughput APIs, every request authenticated | Sensitive 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
| Endpoint | Cache |
|---|---|
GET https://api.trymellonauth.com/.well-known/jwks.json | 1h (Cache-Control: max-age=3600) |
GET https://api.trymellonauth.com/.well-known/openid-configuration | 1h |
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). expcheck applies ±30s clock skew — compensates for client clock drift without widening the replay window materially.iatis strict (future-dated tokens rejected).userIdfalls back to the JWTsubclaim when the token was issued without an explicituser_id(service-to-service tokens). Regular user sessions always carryuser_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
| Error | Cause | Fix |
|---|---|---|
JWT_KID_MISMATCH | Signing key rotated since last cache refresh. | Re-fetch JWKS — jose and jwx do this automatically. Custom code must invalidate cache on unknown kid. |
audience invalid | Token issued for a different client_id. | Confirm aud claim matches your app’s client_id. |
signature verification failed | Wrong issuer or token from another tenant. | Confirm iss and that the token came from your application. |