Skip to content

Verify a token

A resource server receives bearer tokens and signs nobody in. It needs masks and nothing else — the gem’s Rails engine loads only when Rails is already loaded, so an API-only or Rack service takes on jwt and no framework.

verifier = Masks::Client.verifier(
"https://jons.auth.example",
audience: "https://jons.things.example/mcp"
)
claims = verifier.verify(token)

That checks the signature against the issuer’s JWKS, and iss, aud and exp. A failure raises Masks::Client::InvalidToken.

aud. Verify it against your own URL. A token minted for another API is a perfectly valid token signed by an issuer you trust — if you do not check who it was minted for, any API sharing your issuer can replay tokens it receives against you.

iss. Pin it to the tenant you expect. The verifier does this for you, but only because you told it which issuer to construct with — so resolve that from the tenant, not from the token.

Build the verifier from the tenant the request is for:

def verifier_for(tenant, request)
Masks::Client.verifier(
format(ENV.fetch("MASKS_ISSUER_TEMPLATE"), subdomain: tenant.subdomain),
audience: "#{request.base_url}/mcp"
)
end

Because keys are per tenant, a token from the wrong one does not merely fail an iss comparison — its kid is absent from the JWKS you fetched, so it cannot be verified at all.

A verified access token gives you a subject, a tenant, scopes and an audience. It does not give you a name or an email address, and neither does the id token — those are released by /userinfo to whoever presents the token, which is the point of them not being in it.

profile = session.userinfo(access_token)
profile["name"] # => "Jon"
profile["email"] # => "jon@example.com"

Masks::Client::Session#identity does this for you at a callback, merging userinfo underneath the verified id-token claims so the signed values always win.

Issuer caches discovery and JWKS for five minutes. On an unknown kid the verifier invalidates and refetches once, so a key rotation is picked up without a restart and without a fetch per request.

Answer a bad token with a WWW-Authenticate header so the client knows where to go:

response.headers["WWW-Authenticate"] =
%(Bearer error="invalid_token", resource_metadata="#{origin}/.well-known/oauth-protected-resource")