Skip to content

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.json

Example response:

json
{
  "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 kid changes 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:

  1. kty == "RSA" — RSA keys only
  2. use ∈ {null, "sig"} — signing (or unspecified)
  3. alg ∈ {null, "RS256"} — RS256 (or unspecified)
  4. Match kid among 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 / PyJWT PyJWKClient / jwx in 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

json
{
  "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:

ts
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);
ruby
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
)
python
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...",
)
go
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 exp passes, you get an expired_token error
  • 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:

  1. Opt-in — call /oauth/userinfo only on sensitive endpoints to re-verify with logi (with the 15-minute expiry, this is enough in most cases)
  2. Webhook — subscribe to the token.revoked event (logi → RP). On receiving a jwt_jti, add it to a local blocklist
  3. Introspection — call /oauth/introspect to check the active status 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_hashbase64url_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 the access_token ↔ id_token binding only when the token response also carries an access_token, and skips this check when there is no access_token (e.g. refresh-only).

💡 Tip: 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 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.

최종 수정:

Identity가 제품의 신뢰를 만듭니다.