HelloJohn / docs
Authentication

Token Verification

Verify HelloJohn JWTs in your backend — zero network calls, JWKS caching, and language-specific examples for Go, Node.js, Python, and more.

HelloJohn issues signed JWTs (EdDSA / Ed25519). Your backend verifies them locally using HelloJohn's public keys — no network call to HelloJohn on every request.

How verification works

1. User sends request with Authorization: Bearer {access_token}
2. Your backend fetches HelloJohn's public key from JWKS endpoint (cached)
3. Your backend verifies the JWT signature, expiry, issuer, and audience locally
4. If valid → extract claims and handle the request
5. If invalid → return 401

The JWKS endpoint is:

https://your-instance.hellojohn.dev/.well-known/jwks.json

Cache the JWKS response for at least 1 hour. HelloJohn rotates keys infrequently — fetching JWKS on every request adds unnecessary latency and load.

Verification examples

package middleware

import (
    "context"
    "net/http"

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

var keyCache *jwk.Cache

func init() {
    ctx := context.Background()
    keyCache = jwk.NewCache(ctx)
    keyCache.Register("https://your-instance.hellojohn.dev/.well-known/jwks.json",
        jwk.WithMinRefreshInterval(time.Hour),
    )
    // Warm the cache on startup
    keyCache.Refresh(ctx, "https://your-instance.hellojohn.dev/.well-known/jwks.json")
}

func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tokenStr := r.Header.Get("Authorization")
        if len(tokenStr) < 8 || tokenStr[:7] != "Bearer " {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        tokenStr = tokenStr[7:]

        keySet, err := keyCache.Get(r.Context(), "https://your-instance.hellojohn.dev/.well-known/jwks.json")
        if err != nil {
            http.Error(w, "internal error", http.StatusInternalServerError)
            return
        }

        token, err := jwt.Parse([]byte(tokenStr),
            jwt.WithKeySet(keySet),
            jwt.WithValidate(true),
            jwt.WithIssuer("https://your-instance.hellojohn.dev"),
            jwt.WithAudience("your-app"),
        )
        if err != nil {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }

        ctx := context.WithValue(r.Context(), "user", token)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}
import { createRemoteJWKSet, jwtVerify } from 'jose'

const JWKS = createRemoteJWKSet(
  new URL('https://your-instance.hellojohn.dev/.well-known/jwks.json'),
  { cacheMaxAge: 3600_000 } // cache for 1 hour
)

export async function verifyToken(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'https://your-instance.hellojohn.dev',
    audience: 'your-app',
  })
  return payload
}

// Express middleware
export function requireAuth(req, res, next) {
  const auth = req.headers.authorization
  if (!auth?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'unauthorized' })
  }

  verifyToken(auth.slice(7))
    .then(payload => {
      req.user = payload
      next()
    })
    .catch(() => res.status(401).json({ error: 'unauthorized' }))
}
from functools import lru_cache
import time
import jwt
import requests

@lru_cache(maxsize=1)
def get_jwks(ttl_hash=None):
    """Cached JWKS fetch — refreshes every hour."""
    response = requests.get(
        "https://your-instance.hellojohn.dev/.well-known/jwks.json"
    )
    return response.json()

def round_time(ttl=3600):
    return round(time.time() / ttl)

def verify_token(token: str) -> dict:
    jwks = get_jwks(ttl_hash=round_time())
    public_keys = {}
    for key_data in jwks["keys"]:
        kid = key_data["kid"]
        public_keys[kid] = jwt.algorithms.OKPAlgorithm.from_jwk(key_data)

    header = jwt.get_unverified_header(token)
    key = public_keys.get(header["kid"])
    if not key:
        raise ValueError("Unknown key ID")

    return jwt.decode(
        token,
        key,
        algorithms=["EdDSA"],
        audience="your-app",
        issuer="https://your-instance.hellojohn.dev",
    )

# FastAPI dependency
from fastapi import Depends, HTTPException, Header

async def get_current_user(authorization: str = Header(...)):
    if not authorization.startswith("Bearer "):
        raise HTTPException(401)
    try:
        return verify_token(authorization[7:])
    except Exception:
        raise HTTPException(401)
require 'jwt'
require 'net/http'
require 'json'

module HelloJohn
  JWKS_URI = 'https://your-instance.hellojohn.dev/.well-known/jwks.json'

  def self.jwks
    @jwks_fetched_at ||= 0
    if Time.now.to_i - @jwks_fetched_at > 3600
      response = Net::HTTP.get(URI(JWKS_URI))
      @jwks = JSON.parse(response)
      @jwks_fetched_at = Time.now.to_i
    end
    @jwks
  end

  def self.verify(token)
    JWT.decode(token, nil, true, {
      algorithms: ['EdDSA'],
      iss: 'https://your-instance.hellojohn.dev',
      verify_iss: true,
      aud: 'your-app',
      verify_aud: true,
      jwks: jwks
    }).first
  end
end
import com.nimbusds.jose.jwk.source.*;
import com.nimbusds.jose.proc.*;
import com.nimbusds.jwt.proc.*;

public class TokenVerifier {
    private static final DefaultJWTProcessor<SecurityContext> processor;

    static {
        JWKSource<SecurityContext> keySource = JWKSourceBuilder
            .create(new URL("https://your-instance.hellojohn.dev/.well-known/jwks.json"))
            .cache(Duration.ofHours(1), Duration.ofMinutes(5))
            .build();

        processor = new DefaultJWTProcessor<>();
        processor.setJWSKeySelector(
            new JWSVerificationKeySelector<>(JWSAlgorithm.EdDSA, keySource)
        );

        DefaultJWTClaimsVerifier<SecurityContext> claimsVerifier =
            new DefaultJWTClaimsVerifier<>(
                new JWTClaimsSet.Builder()
                    .issuer("https://your-instance.hellojohn.dev")
                    .audience("your-app")
                    .build(),
                Set.of("sub", "iat", "exp")
            );
        processor.setJWTClaimsSetVerifier(claimsVerifier);
    }

    public static JWTClaimsSet verify(String token) throws Exception {
        return processor.process(token, null);
    }
}

Claims reference

After verification, the JWT payload contains:

ClaimTypeDescription
substringUser ID (usr_01HX...)
issstringHelloJohn instance URL
audstringYour application audience
iatnumberIssued at (Unix timestamp)
expnumberExpires at (Unix timestamp)
emailstringUser email
email_verifiedboolEmail verification status
tenant_idstringActive tenant ID
rolesstring[]Roles in the current tenant
scopesstring[]OAuth scopes (if applicable)

Custom claims added via JWT Claims → are also included.

Using the HelloJohn SDK (simpler)

If you're using an official SDK, token verification is handled for you:

// Go SDK — verify + extract user in one call
user, err := hjClient.Auth.VerifyAccessToken(ctx, tokenString)
if err != nil {
    http.Error(w, "unauthorized", 401)
    return
}
fmt.Println(user.ID, user.TenantID)

The SDK internally fetches and caches JWKS, validates claims, and returns a typed User struct.

Troubleshooting

ErrorCauseFix
token is expiredAccess token TTL passedClient must refresh using the refresh token
invalid issueriss doesn't match configCheck HELLOJOHN_INSTANCE_URL in your backend config
invalid audienceaud claim mismatchSet audience in your JWT config to match what HelloJohn issues
unknown key IDJWKS cache stale after key rotationForce-refresh JWKS cache
invalid signatureToken tampered or wrong keyEnsure you're using HelloJohn's JWKS, not a hardcoded key

On this page