JWKS & JWT verification
Source:
en/oauth/jwks.md· Live: https://docs.1pass.dev/en/oauth/jwks LLM-sanitized: internal links absolutized, VitePress containers → admonitions, line numbers in the Jump-to Index reference this rendered file (1-indexed).
📍 Jump-to Index
- L23-L46: ## JWKS endpoint
- L47-L67: ## Key rotation
- L55-L67: ### Selecting the verification key (kty filter)
- L68-L81: ## Payload schema
- L82-L138: ## Verification examples by language
- L139-L143: ## After expiry
- L144-L151: ## If you need to check revocation immediately
- L152-L163: ## id_token (OIDC)
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.
Selecting the verification key (kty filter)
When picking the signing key from the JWKS, don't match on kid alone — also filter by key type:
kty == "RSA"— RSA keys onlyuse ∈ {null, "sig"}— signing (or unspecified)alg ∈ {null, "RS256"}— RS256 (or unspecified)- Match
kidamong the keys that pass the conditions above
This way, even if the JWKS later mixes in another key type such as EC, you still select the RSA signing key precisely. Even when a non-RSA key shares the same kid, the RSA signing key is preferred — making this robust against future key-type expansion. The logi SDK v1.0.1 implements this filter.
Note: standard libraries like
jose/ PyJWTPyJWKClient/jwxin the code examples below perform this filtering internally, so no extra handling is needed. Implement the filter above manually only when you parse the JWKS and select the key yourself.
Payload schema
{
"iss": "https://api.1pass.dev",
"sub": "42",
"aud": "logi_a1b2c3d4...",
"exp": 1734567890,
"iat": 1734566990,
"jti": "9d6f...-...-...",
"scope": "profile:basic email"
}Verification examples by language
Code examples by language/framework:
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—base64url_nopad(SHA256(the UTF-8 bytes of access_token)[first 16 bytes])= the left 128 bits, base64url with no padding (OpenID Connect Core 1.0 §3.1.3.6). It is issued by the logi server, and the SDK v1.0.1 verifies it present-only — it checks theaccess_token ↔ id_tokenbinding only when the token response also carries anaccess_token, and skips this check when there is noaccess_token(e.g. refresh-only).
💡 Tip: Production issuer The
iss/issuercomparison value in all verification code ishttps://api.1pass.dev. This value matches exactly theissuerfield 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 additionally verify nonce (that it matches the value sent in the authorize request) and at_hash (that the value computed from access_token with the formula above matches the claim, present-only) — replay + token binding defense.