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.
The two that are easy to skip
Section titled “The two that are easy to skip”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.
Multi-tenant resource servers
Section titled “Multi-tenant resource servers”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" )endBecause 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.
Names live at userinfo
Section titled “Names live at userinfo”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.
Caching
Section titled “Caching”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.
Challenging
Section titled “Challenging”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")