JWKS & JWT verification
logi issues RS256 JWTs as access tokens. An RP verifies the signature statelessly, or calls /oauth/userinfo to check revocation.
JWKS endpoint
GET /.well-known/jwks.jsonExample response:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "ffaa476406e8abec",
"n": "xqJbzP...",
"e": "AQAB"
}
]
}A Cache-Control: public, max-age=3600 header is attached, so it can be cached for one hour.
Key rotation
- A new key is issued and the
kidchanges once per quarter - The old key is kept in the JWKS for a 90-day grace period (to verify previously issued tokens)
- New issuance is signed only with the active
kid
When implementing as an RP, refresh the JWKS cache on a 1-hour to 1-day basis. On a verification failure, force a refresh once and retry.
Payload schema
{
"iss": "https://api.1pass.dev",
"sub": "42",
"aud": "logi_a1b2c3d4...",
"exp": 1734567890,
"iat": 1734566990,
"jti": "9d6f...-...-...",
"scope": "profile email"
}Verification examples by language
import { jwtVerify, createRemoteJWKSet } from "jose";
const jwks = createRemoteJWKSet(new URL("https://api.1pass.dev/.well-known/jwks.json"));
const { payload } = await jwtVerify(accessToken, jwks, {
issuer: "https://api.1pass.dev",
audience: "logi_a1b2c3d4...", // my app's client_id
});
console.log(payload.sub);require "jwt"
require "open-uri"
jwks_raw = URI.open("https://api.1pass.dev/.well-known/jwks.json").read
jwks = JWT::JWK::Set.new(JSON.parse(jwks_raw))
payload, _header = JWT.decode(
access_token, nil, true,
algorithms: ["RS256"],
iss: "https://api.1pass.dev", verify_iss: true,
aud: ENV["LOGI_CLIENT_ID"], verify_aud: true,
jwks: jwks
)import jwt
import requests
jwks = jwt.PyJWKClient("https://api.1pass.dev/.well-known/jwks.json")
signing_key = jwks.get_signing_key_from_jwt(access_token)
payload = jwt.decode(
access_token, signing_key.key,
algorithms=["RS256"],
issuer="https://api.1pass.dev",
audience="logi_a1b2c3d4...",
)import "github.com/lestrrat-go/jwx/v2/jwk"
jwks, _ := jwk.Fetch(ctx, "https://api.1pass.dev/.well-known/jwks.json")
tok, err := jwt.Parse([]byte(accessToken),
jwt.WithKeySet(jwks),
jwt.WithIssuer("https://api.1pass.dev"),
jwt.WithAudience("logi_a1b2c3d4..."),
)After expiry
- Once
exppasses, you get anexpired_tokenerror - Call /oauth/token again with the refresh token to issue a new access token
If you need to check revocation immediately
Because a JWT is stateless, you cannot tell whether a jti has been revoked from self-verification alone. Use one of these:
- Opt-in — call
/oauth/userinfoonly on sensitive endpoints to re-verify with logi (with the 15-minute expiry, this is enough in most cases) - Webhook — subscribe to the
token.revokedevent (logi → RP). On receiving ajwt_jti, add it to a local blocklist - Introspection — call
/oauth/introspectto check theactivestatus directly
id_token (OIDC)
When the openid scope is requested, the /oauth/token response includes an id_token. Included claims:
iss,sub,aud,exp,iat,nonce(when requested)at_hash— the left 128 bits of SHA256(access_token), base64url (OpenID Connect Core 1.0 §3.1.3.6)
Production issuer
The iss / issuer comparison value in all verification code is https://api.1pass.dev. This value matches exactly the issuer field of /.well-known/openid-configuration — we recommend that an RP fetch it from the discovery document and compare, rather than hardcoding it.
Verify the id_token the same way as the access token, but also compare nonce and at_hash (replay + token binding defense).