Repository: openpubkey/openpubkey
Pull request #88 (2024-01-22 09:34:04)

Verification Redesign
This PR expands the existing `PKTokenVerifier` object into a fully-fledged customizable and parameterized verification functionality. Devs looking for the original object will see this has been removed, however this is unlikely to be felt by users of this library as it was never properly released.

There is a new verifier package that contains this functionality. The `ProviderVerifier` and `CosignerVerifier` allow for custom logic with clearly defined requirements that do not rely on the existence of a `OpenIdProvider`. Because of this logic isolation, the interface has been simplified. It should be noted here that although there was an `Issuer()` function added to the interface, it was unused by the client and easily removed. It was also unreleased.

The `OpenIdProvider` as of the last release was as follows:
```golang
type OpenIdProvider interface {
	RequestTokens(ctx context.Context, cicHash string) (*memguard.LockedBuffer, error)
	PublicKey(ctx context.Context, idt []byte) (crypto.PublicKey, error)
	VerifyCICHash(ctx context.Context, idt []byte, expectedCICHash string) error
	VerifyNonGQSig(ctx context.Context, idt []byte, expectedNonce string) error
}
```
and has been changed to
```golang
type OpenIdProvider interface {
	RequestTokens(ctx context.Context, cicHash string) (*memguard.LockedBuffer, error)
	PublicKey(ctx context.Context, headers jws.Headers) (crypto.PublicKey, error)
	Verifier() verifier.ProviderVerifier
}
```

This may cause issues for library users that have implemented an object that satisfies the `OpenIdProvider` interface as they will have to create a new function. Although, this is an overall improvement for its simplicity and, in most cases, will reduce the amount of code required to implement a satisfying object. In addition, all verification logic has been consolidated for security and maintainability in the `verifier` package and no longer exists in the PKToken object. Users that have developed custom verifiers relying on these functions, should create an Issue if the new `Verifier` object cannot satisfy the existing purposes.

Two small items have been changed:

1. No impact to library users: the `GQSecurityParameter` has been removed from the `client` package and replaced with a new, helpful GQ SignerVerifier constructor: `New256SignerVerifier`.
2. Impact to library users:`ErrNonGQUnsupported` has been moved from the `client` to the `verifier`

In addition, three functions have been deprecated. Any users of the library may rely on these as they were exported functions in the last release:

1. `VerifyPKToken(ctx context.Context, pkt *pktoken.PKToken, provider OpenIdProvider) error`
has been deprecated in favor of:
```golang
pktVerifier, err := verifier.New(provider.Verifier())
err = pktVerifier.VerifyPKToken(context.Background(), pkt)
```
2. `ExtractClaim(idt []byte, claimName string) (string, error)`
Has been deprecated because it is not used internally.
3. `DiscoverPublicKey(ctx context.Context, headers jws.Headers, issuer string) (crypto.PublicKey, error)`
Has been moved to its own package `discover` to prevent import cycles and its name changed as it no longer has the beneficial context of its location but its accepted and returned args are the same.
```golang
ProviderPublicKey(ctx context.Context, headers jws.Headers, issuer string) (crypto.PublicKey, error)
```


EthanHeilman: @lgmugnier  I'd like to get their PR merged. Moving the verifier to a verifier package would be great. How far away is this from being ready for a code review.
EthanHeilman: Very excited to see this is ready for review! I'm making some minor comments right now, let me do a deeper dive tomorrow.

Patch:
diff --git a/README.md b/README.md
index 6abae8d..f3179de 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,8 @@ signedMsg, err := pkt.NewSignedMessage(msg, opkClient.GetSigner())
 To verify a signed message, we first verify that the PK Token `pkt` is issued by the OP (Google). Then we use the PK Token to verify the signed message.
 
 ```golang
-err = client.VerifyPKToken(context.Background(), pkt, op)
+pktVerifier, err := verifier.New(provider.Verifier())
+err = pktVerifier.VerifyPKToken(context.Background(), pkt)
 msg, err := pkt.VerifySignedMessage(osm)
 ```
 
diff --git a/client/client.go b/client/client.go
index 4e37bca..c4b9efa 100644
--- a/client/client.go
+++ b/client/client.go
@@ -32,15 +32,32 @@ import (
 	"github.com/openpubkey/openpubkey/pktoken"
 	"github.com/openpubkey/openpubkey/pktoken/clientinstance"
 	"github.com/openpubkey/openpubkey/util"
+	"github.com/openpubkey/openpubkey/verifier"
 )
 
-// OpkClient is the OpenPubkey client
+// Interface for interacting with the OP (OpenID Provider)
+type OpenIdProvider interface {
+	RequestTokens(ctx context.Context, cicHash string) (*memguard.LockedBuffer, error)
+	PublicKey(ctx context.Context, headers jws.Headers) (crypto.PublicKey, error)
+	Verifier() verifier.ProviderVerifier
+}
+
+type BrowserOpenIdProvider interface {
+	OpenIdProvider
+	HookHTTPSession(h http.HandlerFunc)
+}
+
+type PKTokenVerifier interface {
+	VerifyPKToken(ctx context.Context, pkt *pktoken.PKToken, extraChecks ...verifier.Check) error
+}
+
 type OpkClient struct {
-	Op     OpenIdProvider
-	cosP   *CosignerProvider
-	signer crypto.Signer
-	alg    jwa.KeyAlgorithm
-	signGQ bool // Default is false
+	Op       OpenIdProvider
+	cosP     *CosignerProvider
+	signer   crypto.Signer
+	alg      jwa.KeyAlgorithm
+	signGQ   bool // Default is false
+	verifier PKTokenVerifier
 }
 
 // ClientOpts contains options for constructing an OpkClient
@@ -78,6 +95,13 @@ func WithCosignerProvider(cosP *CosignerProvider) ClientOpts {
 	}
 }
 
+// WithCustomVerifier specifies a custom verifier to use instead of default
+func WithCustomVerifier(verifier PKTokenVerifier) ClientOpts {
+	return func(o *OpkClient) {
+		o.verifier = verifier
+	}
+}
+
 // New returns a new client.OpkClient. The op argument should be the
 // OpenID Provider you want to authenticate against.
 func New(op OpenIdProvider, opts ...ClientOpts) (*OpkClient, error) {
@@ -109,6 +133,23 @@ func New(op OpenIdProvider, opts ...ClientOpts) (*OpkClient, error) {
 		client.signer = signer
 	}
 
+	// If there is no provided client verifier, create our default
+	if client.verifier == nil {
+		var pktVerifier *verifier.Verifier
+		var err error
+		if client.cosP != nil {
+			cosignerVerifier := verifier.NewCosignerVerifier(client.cosP.Issuer, verifier.CosignerVerifierOpts{})
+			pktVerifier, err = verifier.New(op.Verifier(), verifier.WithCosignerVerifiers(cosignerVerifier))
+		} else {
+			pktVerifier, err = verifier.New(op.Verifier())
+		}
+
+		if err != nil {
+			return nil, err
+		}
+		client.verifier = pktVerifier
+	}
+
 	return client, nil
 }
 
@@ -177,7 +218,10 @@ func (o *OpkClient) OidcAuth(
 	extraClaims map[string]any,
 	signGQ bool,
 ) (*pktoken.PKToken, error) {
-	// Use our signing key to generate a JWK key with the alg header set
+	// keep track of any additional verifierChecks for the verifier
+	verifierChecks := []verifier.Check{}
+
+	// Use our signing key to generate a JWK key and set the "alg" header
 	jwkKey, err := jwk.PublicKeyOf(signer)
 	if err != nil {
 		return nil, err
@@ -193,14 +237,14 @@ func (o *OpkClient) OidcAuth(
 		return nil, fmt.Errorf("failed to instantiate client instance claims: %w", err)
 	}
 
-	// Define our OIDC nonce as a commitment to the client instance claims
-	nonce, err := cic.Hash()
+	// Define our commitment as the hash of the client instance claims
+	commitment, err := cic.Hash()
 	if err != nil {
-		return nil, fmt.Errorf("error getting nonce: %w", err)
+		return nil, fmt.Errorf("error calculating client instance claim commitment: %w", err)
 	}
 
 	// Use the commitment nonce to complete the OIDC flow and get an ID token from the provider
-	idTokenLB, err := o.Op.RequestTokens(ctx, string(nonce))
+	idTokenLB, err := o.Op.RequestTokens(ctx, string(commitment))
 	// idTokenLB is the ID Token in a memguard LockedBuffer, this is done
 	// because the ID Token contains the OPs RSA signature which is a secret
 	// in GQ signatures. For non-GQ signatures OPs RSA signature is considered
@@ -229,13 +273,17 @@ func (o *OpkClient) OidcAuth(
 
 	opKey, err := o.Op.PublicKey(ctx, headers)
 	if err != nil {
-		return nil, fmt.Errorf("error getting OP public key: %w", err)
+		return nil, err
 	}
 
+	// sign GQ256
 	if signGQ {
-		rsaPubKey := opKey.(*rsa.PublicKey)
+		rsaKey, ok := opKey.(*rsa.PublicKey)
+		if !ok {
+			return nil, fmt.Errorf("gq signatures require original provider to have signed with an RSA key")
+		}
 
-		sv, err := gq.NewSignerVerifier(rsaPubKey, GQSecurityParameter)
+		sv, err := gq.New256SignerVerifier(rsaKey)
 		if err != nil {
 			return nil, fmt.Errorf("error creating GQ signer: %w", err)
 		}
@@ -244,6 +292,9 @@ func (o *OpkClient) OidcAuth(
 			return nil, fmt.Errorf("error creating GQ signature: %w", err)
 		}
 		idTokenLB = memguard.NewBufferFromBytes(gqToken)
+
+		// Make sure we force the check for GQ during verification
+		verifierChecks = append(verifierChecks, verifier.GQOnly())
 	}
 
 	// We are now past the point where the ID Token should be treated
@@ -257,15 +308,19 @@ func (o *OpkClient) OidcAuth(
 		return nil, fmt.Errorf("error creating PK Token: %w", err)
 	}
 
-	err = VerifyPKToken(ctx, pkt, o.Op)
-	if err != nil {
-		return nil, fmt.Errorf("error verifying PK Token: %w", err)
+	if err := pkt.AddJKTHeader(opKey); err != nil {
+		return nil, fmt.Errorf("error adding JKT header: %w", err)
 	}
 
-	err = pkt.AddJKTHeader(opKey)
+	pktVerifier, err := verifier.New(o.Op.Verifier())
 	if err != nil {
-		return nil, fmt.Errorf("error adding JKT header: %w", err)
+		return nil, err
 	}
+
+	if err := pktVerifier.VerifyPKToken(ctx, pkt, verifierChecks...); err != nil {
+		return nil, fmt.Errorf("error verifying PK Token: %w", err)
+	}
+
 	return pkt, nil
 }
 
diff --git a/client/mocks/oidp_server.go b/client/mocks/oidp_server.go
new file mode 100644
index 0000000..e29f98c
--- /dev/null
+++ b/client/mocks/oidp_server.go
@@ -0,0 +1,123 @@
+// Copyright 2024 OpenPubkey
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package mocks
+
+import (
+	"crypto"
+	"encoding/json"
+	"fmt"
+	"net"
+	"net/http"
+
+	"github.com/google/uuid"
+	"github.com/lestrrat-go/jwx/v2/jwa"
+	"github.com/lestrrat-go/jwx/v2/jwk"
+	"github.com/zitadel/oidc/v2/pkg/oidc"
+)
+
+const (
+	jwksEndpoint            = "/.well-known/jwks.json"
+	wellKnownConfigEndpoint = "/.well-known/openid-configuration"
+)
+
+type OIDPServer struct {
+	listener  net.Listener
+	Server    *http.ServeMux
+	uri       string
+	kid       string
+	jwksBytes []byte
+}
+
+// A very simple JWKS server for our MFA Cosigner example code.
+func NewOIDPServer(signer crypto.Signer, alg jwa.SignatureAlgorithm) (*OIDPServer, error) {
+	kid := uuid.New().String()
+
+	// Generate our JWKS using our signing key
+	jwkKey, err := jwk.PublicKeyOf(signer)
+	if err != nil {
+		return nil, err
+	}
+	jwkKey.Set(jwk.AlgorithmKey, alg)
+	jwkKey.Set(jwk.KeyIDKey, kid)
+
+	// Put our jwk into a set
+	keySet := jwk.NewSet()
+	keySet.AddKey(jwkKey)
+
+	// Now convert our key set into the raw bytes for printing later
+	keySetBytes, _ := json.MarshalIndent(keySet, "", "  ")
+	if err != nil {
+		return nil, err
+	}
+
+	s := &OIDPServer{
+		Server:    http.NewServeMux(),
+		jwksBytes: keySetBytes,
+		kid:       kid,
+	}
+
+	// Host our JWKS at a localhost url
+	s.Server.HandleFunc(jwksEndpoint, s.printJWKS)
+	s.Server.HandleFunc(wellKnownConfigEndpoint, s.printWellKnownConfig)
+
+	return s, nil
+}
+
+func (s *OIDPServer) Serve() error {
+	// Find an empty port
+	listener, err := net.Listen("tcp", ":0")
+	if err != nil {
+		return fmt.Errorf("failed to bind to an available port: %w", err)
+	}
+
+	s.listener = listener
+	s.uri = fmt.Sprintf("http://localhost:%d", listener.Addr().(*net.TCPAddr).Port)
+
+	go func() {
+		http.Serve(listener, s.Server)
+	}()
+
+	return nil
+}
+
+func (s *OIDPServer) Shutdown() error {
+	return s.listener.Close()
+}
+
+func (s *OIDPServer) KID() string {
+	return s.kid
+}
+
+func (s *OIDPServer) URI() string {
+	return s.uri
+}
+
+func (s *OIDPServer) printJWKS(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/json")
+	w.Write(s.jwksBytes)
+}
+
+func (s *OIDPServer) printWellKnownConfig(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/json")
+	config := oidc.DiscoveryConfiguration{
+		Issuer:  s.uri,
+		JwksURI: fmt.Sprintf("%s%s", s.uri, jwksEndpoint),
+	}
+
+	configBytes, _ := json.Marshal(config)
+	w.Write(configBytes)
+}
diff --git a/client/mocks/provider.go b/client/mocks/provider.go
new file mode 100644
index 0000000..ea9fbd6
--- /dev/null
+++ b/client/mocks/provider.go
@@ -0,0 +1,177 @@
+// This code was originally generated by mockery v2.32.0 and modified
+
+package mocks
+
+import (
+	"context"
+	"crypto"
+	"crypto/rand"
+	"crypto/rsa"
+	"encoding/json"
+	"time"
+
+	"github.com/awnumar/memguard"
+	"github.com/lestrrat-go/jwx/v2/jwa"
+	"github.com/lestrrat-go/jwx/v2/jws"
+	"github.com/openpubkey/openpubkey/verifier"
+	"github.com/stretchr/testify/mock"
+)
+
+// OpenIdProvider is an autogenerated mock type for the OpenIdProvider type
+type OpenIdProvider struct {
+	mock.Mock
+}
+
+// Verifier provides a mock function with given fields:
+func (_m *OpenIdProvider) Verifier() verifier.ProviderVerifier {
+	ret := _m.Called()
+
+	var r0 verifier.ProviderVerifier
+	if rf, ok := ret.Get(0).(func() verifier.ProviderVerifier); ok {
+		r0 = rf()
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(verifier.ProviderVerifier)
+		}
+	}
+
+	return r0
+}
+
+// PublicKey provides a mock function with given fields: ctx, headers
+
+func (_m *OpenIdProvider) PublicKey(ctx context.Context, headers jws.Headers) (crypto.PublicKey, error) {
+	ret := _m.Called(ctx, headers)
+	var r0 crypto.PublicKey
+	var r1 error
+
+	if rf, ok := ret.Get(0).(func(context.Context, jws.Headers) (crypto.PublicKey, error)); ok {
+		return rf(ctx, headers)
+	}
+
+	if rf, ok := ret.Get(0).(func(context.Context, jws.Headers) crypto.PublicKey); ok {
+		r0 = rf(ctx, headers)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(crypto.PublicKey)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, jws.Headers) error); ok {
+		r1 = rf(ctx, headers)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+
+}
+
+// RequestTokens provides a mock function with given fields: ctx, cicHash
+func (_m *OpenIdProvider) RequestTokens(ctx context.Context, cicHash string) (*memguard.LockedBuffer, error) {
+	ret := _m.Called(ctx, cicHash)
+
+	var r0 *memguard.LockedBuffer
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, string) (*memguard.LockedBuffer, error)); ok {
+		return rf(ctx, cicHash)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, string) *memguard.LockedBuffer); ok {
+		r0 = rf(ctx, cicHash)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*memguard.LockedBuffer)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
+		r1 = rf(ctx, cicHash)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// NewOpenIdProvider creates a new instance of OpenIdProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func NewOpenIdProvider(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *OpenIdProvider {
+	mock := &OpenIdProvider{}
+	mock.Mock.Test(t)
+
+	return mock
+}
+
+// This function creates a new, correctly functioning Provider that can be used for test purposes, for finer grain control
+// or error testing, please use the function above.
+//
+// extraClaims: Allows for optional id token payload values to be supplied, these values will overwrite existing ones of the same name.
+func NewMockOpenIdProvider(
+	t interface {
+		mock.TestingT
+		Cleanup(func())
+	},
+	extraClaims map[string]any,
+) (*OpenIdProvider, error) {
+	alg := jwa.RS256
+	signingKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		return nil, err
+	}
+
+	oidpServer, err := NewOIDPServer(signingKey, alg)
+	if err != nil {
+		return nil, err
+	}
+	if err := oidpServer.Serve(); err != nil {
+		return nil, err
+	}
+	t.Cleanup(func() { oidpServer.Shutdown() })
+
+	// Use our OIDP server uri as the mock issuer
+	issuer := oidpServer.URI()
+
+	provider := NewOpenIdProvider(t)
+	provider.On("Verifier").Return(verifier.NewProviderVerifier(issuer, "nonce", verifier.ProviderVerifierOpts{SkipClientIDCheck: true}))
+	provider.On("PublicKey", mock.Anything, mock.Anything).Return(signingKey.Public(), nil)
+	provider.On("RequestTokens", mock.Anything, mock.Anything).Return(func(ctx context.Context, cicHash string) (*memguard.LockedBuffer, error) {
+		headers := jws.NewHeaders()
+		headers.Set(jws.AlgorithmKey, alg)
+		headers.Set(jws.KeyIDKey, oidpServer.KID())
+		headers.Set(jws.TypeKey, "JWT")
+
+		// Default, minimum viable claims for functional id token payload
+		payload := map[string]any{
+			"sub":   "me",
+			"aud":   "also me",
+			"iss":   issuer,
+			"iat":   time.Now().Unix(),
+			"nonce": cicHash,
+		}
+		// Add/replace values in payload map with those provided
+		for k, v := range extraClaims {
+			payload[k] = v
+		}
+
+		payloadBytes, err := json.Marshal(payload)
+		if err != nil {
+			return nil, err
+		}
+
+		token, err := jws.Sign(
+			payloadBytes,
+			jws.WithKey(
+				alg,
+				signingKey,
+				jws.WithProtectedHeaders(headers),
+			),
+		)
+
+		return memguard.NewBufferFromBytes(token), err
+	})
+
+	return provider, nil
+}
diff --git a/client/oidc.go b/client/oidc.go
index b9b4f53..08e62e5 100644
--- a/client/oidc.go
+++ b/client/oidc.go
@@ -19,26 +19,17 @@ package client
 import (
 	"context"
 	"crypto"
-	"crypto/rsa"
 	"encoding/json"
 	"fmt"
-	"net/http"
 	"strings"
 
-	"github.com/awnumar/memguard"
-	"github.com/lestrrat-go/jwx/v2/jwa"
-	"github.com/lestrrat-go/jwx/v2/jwk"
 	"github.com/lestrrat-go/jwx/v2/jws"
-	"github.com/openpubkey/openpubkey/gq"
+	"github.com/openpubkey/openpubkey/client/providers/discover"
 	"github.com/openpubkey/openpubkey/pktoken"
 	"github.com/openpubkey/openpubkey/util"
-	oidcclient "github.com/zitadel/oidc/v2/pkg/client"
+	"github.com/openpubkey/openpubkey/verifier"
 )
 
-const GQSecurityParameter = 256
-
-var ErrNonGQUnsupported = fmt.Errorf("non-GQ signatures are not supported")
-
 type OidcClaims struct {
 	Issuer     string `json:"iss"`
 	Subject    string `json:"sub"`
@@ -83,203 +74,22 @@ func (id *OidcClaims) UnmarshalJSON(data []byte) error {
 	return nil
 }
 
-// Interface for interacting with the OP (OpenID Provider)
-type OpenIdProvider interface {
-	Issuer() string
-	RequestTokens(ctx context.Context, cicHash string) (*memguard.LockedBuffer, error)
-	PublicKey(ctx context.Context, headers jws.Headers) (crypto.PublicKey, error)
-	VerifyCICHash(ctx context.Context, idt []byte, expectedCICHash string) error
-	VerifyNonGQSig(ctx context.Context, idt []byte, expectedNonce string) error
-}
-
-// Interface for interacting with the OP (OpenID Provider)
-type BrowserOpenIdProvider interface {
-	OpenIdProvider
-	HookHTTPSession(h http.HandlerFunc)
-}
-
+// Deprecated: please use the verifier package directly as shown in function
 func VerifyPKToken(ctx context.Context, pkt *pktoken.PKToken, provider OpenIdProvider) error {
-	return PKTokenVerifier{
-		AllowedProviders: []OpenIdProvider{provider},
-	}.Verify(ctx, pkt)
-}
-
-type PKTokenVerifier struct {
-	AllowedProviders []OpenIdProvider
-	AllowedCosigners []CosignerProvider
-}
-
-func (v PKTokenVerifier) Verify(ctx context.Context, pkt *pktoken.PKToken) error {
-	// If no providers (OPs) are set in the allowlist verify will always fail.
-	if len(v.AllowedProviders) == 0 {
-		return fmt.Errorf("no allowed providers (allowed OPs) set, you must set at least one provider")
-	}
-
-	var pktOp struct {
-		Issuer string `json:"iss"`
-	}
-	if err := json.Unmarshal(pkt.Payload, &pktOp); err != nil {
-		return err
-	}
-
-	//TODO: This function does not handle multiple providers with the same
-	// issuer and different audiences. This does not handle the case where the
-	// same provider (OP) is specified twice in an allowlist with different
-	// audiences. Instead it will match on the first provider with the correct
-	// issuer and then verify using that provider. If that provider has the
-	// wrong audience it will throw an error even if there was a provider in
-	// the allowlist that had both the correct audience and the correct issuer.
-	// See https://github.com/openpubkey/openpubkey/issues/84
-	var provider OpenIdProvider
-	for _, allowedOp := range v.AllowedProviders {
-		if allowedOp.Issuer() == pktOp.Issuer {
-			provider = allowedOp
-			break
-		}
-	}
-	if provider == nil {
-		return fmt.Errorf("the OP issuer (%s) in the PK Token is not an allowed issuer", pktOp.Issuer)
-	}
-	cic, err := pkt.GetCicValues()
+	pktVerifier, err := verifier.New(provider.Verifier())
 	if err != nil {
 		return err
 	}
 
-	commitment, err := cic.Hash()
-	if err != nil {
-		return err
-	}
-
-	idt, err := pkt.Compact(pkt.Op)
-	if err != nil {
-		return err
-	}
-
-	issuer, err := ExtractClaim(idt, "iss")
-	if err != nil {
-		return err
-	}
-
-	if issuer != provider.Issuer() {
-		return fmt.Errorf("expected token to have issuer %s, got %s", provider.Issuer(), issuer)
-	}
-
-	alg, ok := pkt.ProviderAlgorithm()
-	if !ok {
-		return fmt.Errorf("provider algorithm type missing")
-	}
-
-	switch alg {
-	case gq.GQ256:
-		origHeadersB64, err := gq.OriginalJWTHeaders(idt)
-		if err != nil {
-			return err
-		}
-
-		origHeaders := jws.NewHeaders()
-		err = parseJWTSegment(origHeadersB64, &origHeaders)
-		if err != nil {
-			return err
-		}
-
-		alg := origHeaders.Algorithm()
-		if alg != jwa.RS256 {
-			return fmt.Errorf("expected original headers to contain RS256 alg, got %s", alg)
-		}
-
-		// TODO: this needs to get the public key from a log of historic public keys based on the iat time in the token
-		pubKey, err := provider.PublicKey(ctx, origHeaders)
-		if err != nil {
-			return fmt.Errorf("failed to get OP public key: %w", err)
-		}
-
-		rsaPubKey, ok := pubKey.(*rsa.PublicKey)
-		if !ok {
-			return fmt.Errorf("expected public key to be a *rsa.PublicKey, got %T", pubKey)
-		}
-
-		err = pkt.VerifyGQSig(rsaPubKey, GQSecurityParameter)
-		if err != nil {
-			return fmt.Errorf("error verifying OP GQ signature on PK Token: %w", err)
-		}
-
-		err = provider.VerifyCICHash(ctx, idt, string(commitment))
-		if err != nil {
-			return fmt.Errorf("failed to verify CIC hash: %w", err)
-		}
-	case jwa.RS256:
-		err = provider.VerifyNonGQSig(ctx, idt, string(commitment))
-		if err != nil {
-			if err == ErrNonGQUnsupported {
-				return fmt.Errorf("oidc provider doesn't support non-GQ signatures")
-			}
-			return fmt.Errorf("failed to verify signature from OIDC provider: %w", err)
-		}
-	}
-
-	err = pkt.VerifyCicSig()
-	if err != nil {
-		return fmt.Errorf("error verifying CIC signature on PK Token: %w", err)
-	}
-
-	// If no cosigner is set, then we don't require a cosigner.
-	if len(v.AllowedCosigners) != 0 {
-		if pkt.Cos == nil {
-			return fmt.Errorf("cosigner required but no cosigner signature (COS) set in PK Token")
-		}
-
-		var cos *CosignerProvider
-		if cosIss, ok := pkt.Cos.ProtectedHeaders().Get("iss"); !ok {
-			return fmt.Errorf("no COS issuer set in the PK Token")
-		} else {
-			for _, allowedCos := range v.AllowedCosigners {
-				if allowedCos.Issuer == cosIss {
-					cos = &allowedCos
-					break // Matching cosigner found on allowlist
-				}
-			}
-			// if cos is nil, the PK Token's cosigner is not on the allowlist
-			if cos == nil {
-				return fmt.Errorf("the COS issuer (%s) in the PK Token is not an list of allowed issuers", cosIss)
-			} else if err := pkt.VerifyCosSig(); err != nil {
-				return fmt.Errorf("error verifying cosigner signature on PK Token: %w", err)
-			}
-		}
-	}
-
-	return nil
+	return pktVerifier.VerifyPKToken(ctx, pkt)
 }
 
+// Deprecated: please use function in discover package
 func DiscoverPublicKey(ctx context.Context, headers jws.Headers, issuer string) (crypto.PublicKey, error) {
-	discConf, err := oidcclient.Discover(issuer, http.DefaultClient)
-	if err != nil {
-		return nil, fmt.Errorf("failed to call OIDC discovery endpoint: %w", err)
-	}
-
-	jwks, err := jwk.Fetch(ctx, discConf.JwksURI)
-	if err != nil {
-		return nil, fmt.Errorf("failed to fetch to JWKS: %w", err)
-	}
-
-	kid := headers.KeyID()
-	key, ok := jwks.LookupKeyID(kid)
-	if !ok {
-		return nil, fmt.Errorf("key %q isn't in JWKS", kid)
-	}
-
-	if key.Algorithm() != jwa.RS256 {
-		return nil, fmt.Errorf("expected alg to be RS256 in JWK with kid %q for OP %q, got %q", kid, issuer, key.Algorithm())
-	}
-
-	pubKey := new(rsa.PublicKey)
-	err = key.Raw(pubKey)
-	if err != nil {
-		return nil, fmt.Errorf("failed to decode public key: %w", err)
-	}
-
-	return pubKey, err
+	return discover.ProviderPublicKey(ctx, headers, issuer)
 }
 
+// Deprecated: unused internally
 func ExtractClaim(idt []byte, claimName string) (string, error) {
 	_, payloadB64, _, err := jws.SplitCompact(idt)
 	if err != nil {
diff --git a/client/providers/discover/discover.go b/client/providers/discover/discover.go
new file mode 100644
index 0000000..9f5ed70
--- /dev/null
+++ b/client/providers/discover/discover.go
@@ -0,0 +1,45 @@
+package discover
+
+import (
+	"context"
+	"crypto"
+	"crypto/rsa"
+	"fmt"
+	"net/http"
+
+	"github.com/lestrrat-go/jwx/v2/jwa"
+	"github.com/lestrrat-go/jwx/v2/jwk"
+	"github.com/lestrrat-go/jwx/v2/jws"
+
+	oidcclient "github.com/zitadel/oidc/v2/pkg/client"
+)
+
+func ProviderPublicKey(ctx context.Context, headers jws.Headers, issuer string) (crypto.PublicKey, error) {
+	discConf, err := oidcclient.Discover(issuer, http.DefaultClient)
+	if err != nil {
+		return nil, fmt.Errorf("failed to call OIDC discovery endpoint: %w", err)
+	}
+
+	jwks, err := jwk.Fetch(ctx, discConf.JwksURI)
+	if err != nil {
+		return nil, fmt.Errorf("failed to fetch to JWKS: %w", err)
+	}
+
+	kid := headers.KeyID()
+	key, ok := jwks.LookupKeyID(kid)
+	if !ok {
+		return nil, fmt.Errorf("key %s isn't in JWKS", kid)
+	}
+
+	if key.Algorithm() != jwa.RS256 {
+		return nil, fmt.Errorf("expected alg to be RS256 in JWK with kid %q for OP %q, got %q", kid, issuer, key.Algorithm())
+	}
+
+	pubKey := new(rsa.PublicKey)
+	err = key.Raw(pubKey)
+	if err != nil {
+		return nil, fmt.Errorf("failed to decode public key: %w", err)
+	}
+
+	return pubKey, err
+}
diff --git a/client/providers/github_actions.go b/client/providers/github_actions.go
index 856580f..27daae9 100644
--- a/client/providers/github_actions.go
+++ b/client/providers/github_actions.go
@@ -13,6 +13,8 @@ import (
 	"github.com/awnumar/memguard"
 	"github.com/lestrrat-go/jwx/v2/jws"
 	"github.com/openpubkey/openpubkey/client"
+	"github.com/openpubkey/openpubkey/client/providers/discover"
+	"github.com/openpubkey/openpubkey/verifier"
 )
 
 const githubIssuer = "https://token.actions.githubusercontent.com"
@@ -68,25 +70,12 @@ func buildTokenURL(rawTokenURL, audience string) (string, error) {
 	return parsedURL.String(), nil
 }
 
-func (g *GithubOp) VerifyCICHash(ctx context.Context, idt []byte, expectedCICHash string) error {
-	cicHash, err := client.ExtractClaim(idt, "aud")
-	if err != nil {
-		return err
-	}
-
-	if cicHash != expectedCICHash {
-		return fmt.Errorf("aud claim doesn't match, got %q, expected %q", cicHash, expectedCICHash)
-	}
-
-	return nil
-}
-
-func (g *GithubOp) Issuer() string {
-	return githubIssuer
+func (g *GithubOp) Verifier() verifier.ProviderVerifier {
+	return verifier.NewProviderVerifier(githubIssuer, "aud", verifier.ProviderVerifierOpts{GQOnly: true, SkipClientIDCheck: true})
 }
 
 func (g *GithubOp) PublicKey(ctx context.Context, headers jws.Headers) (crypto.PublicKey, error) {
-	return client.DiscoverPublicKey(ctx, headers, githubIssuer)
+	return discover.ProviderPublicKey(ctx, headers, githubIssuer)
 }
 
 func (g *GithubOp) RequestTokens(ctx context.Context, cicHash string) (*memguard.LockedBuffer, error) {
@@ -126,7 +115,3 @@ func (g *GithubOp) RequestTokens(ctx context.Context, cicHash string) (*memguard
 
 	return jwt.Value, err
 }
-
-func (*GithubOp) VerifyNonGQSig(context.Context, []byte, string) error {
-	return client.ErrNonGQUnsupported
-}
diff --git a/client/providers/google.go b/client/providers/google.go
index add0c8a..3c4e0f6 100644
--- a/client/providers/google.go
+++ b/client/providers/google.go
@@ -28,7 +28,9 @@ import (
 	"github.com/google/uuid"
 	"github.com/lestrrat-go/jwx/v2/jws"
 	"github.com/openpubkey/openpubkey/client"
+	"github.com/openpubkey/openpubkey/client/providers/discover"
 	"github.com/openpubkey/openpubkey/util"
+	"github.com/openpubkey/openpubkey/verifier"
 	"github.com/sirupsen/logrus"
 	"github.com/zitadel/oidc/v2/pkg/client/rp"
 	"github.com/zitadel/oidc/v2/pkg/oidc"
@@ -42,6 +44,7 @@ var (
 )
 
 const googleIssuer = "https://accounts.google.com"
+const googleAudience = "992028499768-ce9juclb3vvckh23r83fjkmvf1lvjq18.apps.googleusercontent.com"
 
 type GoogleOp struct {
 	ClientID        string
@@ -68,7 +71,7 @@ func (g *GoogleOp) RequestTokens(ctx context.Context, cicHash string) (*memguard
 	options = append(options, rp.WithPKCE(cookieHandler))
 
 	provider, err := rp.NewRelyingPartyOIDC(
-		g.Issuer(), g.ClientID, g.ClientSecret, g.RedirectURI,
+		googleIssuer, g.ClientID, g.ClientSecret, g.RedirectURI,
 		g.Scopes, options...)
 	if err != nil {
 		return nil, fmt.Errorf("error creating provider: %w", err)
@@ -148,46 +151,12 @@ func (g *GoogleOp) RequestTokens(ctx context.Context, cicHash string) (*memguard
 	}
 }
 
-func (g *GoogleOp) VerifyCICHash(ctx context.Context, idt []byte, expectedCICHash string) error {
-	cicHash, err := client.ExtractClaim(idt, "nonce")
-	if err != nil {
-		return err
-	}
-
-	if cicHash != expectedCICHash {
-		return fmt.Errorf("nonce claim doesn't match, got %q, expected %q", cicHash, expectedCICHash)
-	}
-
-	return nil
-}
-
-func (g *GoogleOp) Issuer() string {
-	return googleIssuer
+func (g *GoogleOp) Verifier() verifier.ProviderVerifier {
+	return verifier.NewProviderVerifier(googleIssuer, "nonce", verifier.ProviderVerifierOpts{ClientID: googleAudience})
 }
 
 func (g *GoogleOp) PublicKey(ctx context.Context, headers jws.Headers) (crypto.PublicKey, error) {
-	return client.DiscoverPublicKey(ctx, headers, googleIssuer)
-}
-
-func (g *GoogleOp) VerifyNonGQSig(ctx context.Context, idt []byte, expectedNonce string) error {
-	options := []rp.Option{
-		rp.WithVerifierOpts(rp.WithIssuedAtOffset(issuedAtOffset), rp.WithNonce(func(ctx context.Context) string { return expectedNonce })),
-	}
-
-	googleRP, err := rp.NewRelyingPartyOIDC(
-		googleIssuer, g.ClientID, g.ClientSecret, g.RedirectURI, g.Scopes,
-		options...)
-
-	if err != nil {
-		return fmt.Errorf("failed to create RP to verify token: %w", err)
-	}
-
-	_, err = rp.VerifyIDToken[*oidc.IDTokenClaims](ctx, string(idt), googleRP.IDTokenVerifier())
-	if err != nil {
-		return fmt.Errorf("error verifying OP signature on PK Token (ID Token invalid): %w", err)
-	}
-
-	return nil
+	return discover.ProviderPublicKey(ctx, headers, googleIssuer)
 }
 
 // HookHTTPSession provides a means to hook the HTTP Server session resulting
diff --git a/client/providers/mocks.go b/client/providers/mocks.go
deleted file mode 100644
index a47706a..0000000
--- a/client/providers/mocks.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package providers
-
-import (
-	"context"
-	"crypto"
-	"time"
-
-	"github.com/awnumar/memguard"
-	"github.com/lestrrat-go/jwx/v2/jwa"
-	"github.com/lestrrat-go/jwx/v2/jws"
-	"github.com/lestrrat-go/jwx/v2/jwt"
-	"github.com/lestrrat-go/jwx/v2/jwt/openid"
-	"github.com/openpubkey/openpubkey/util"
-)
-
-const (
-	MockIssuer   = "me"
-	MockAudience = "also_me"
-)
-
-type MockOpenIdProvider struct {
-	alg    jwa.KeyAlgorithm
-	signer crypto.Signer
-}
-
-func NewMockOpenIdProvider() (*MockOpenIdProvider, error) {
-	alg := jwa.RS256
-	signingKey, err := util.GenKeyPair(alg)
-	if err != nil {
-		return nil, err
-	}
-
-	return &MockOpenIdProvider{
-		alg:    alg,
-		signer: signingKey,
-	}, nil
-}
-
-func (m *MockOpenIdProvider) RequestTokens(ctx context.Context, cicHash string) (*memguard.LockedBuffer, error) {
-	token := openid.New()
-
-	token.Set("nonce", cicHash)
-	token.Set("email", "arthur.aardvark@example.com")
-
-	// Required token payload values for OpenID
-	token.Set(jwt.IssuerKey, MockIssuer)
-	token.Set(jwt.AudienceKey, MockAudience)
-	token.Set(jwt.IssuedAtKey, time.Now().Unix())
-	token.Set(jwt.ExpirationKey, time.Now().Add(24*time.Hour).Unix())
-	token.Set(jwt.SubjectKey, "1234567890")
-
-	// Sign the token with the secret key
-	signedToken, err := jwt.Sign(token, jwt.WithKey(m.alg, m.signer))
-	if err != nil {
-		return nil, err
-	}
-	return memguard.NewBufferFromBytes(signedToken), nil
-}
-
-func (m *MockOpenIdProvider) Issuer() string {
-	return MockIssuer
-}
-
-func (m *MockOpenIdProvider) PublicKey(ctx context.Context, headers jws.Headers) (crypto.PublicKey, error) {
-	return m.signer.Public(), nil
-}
-
-func (m *MockOpenIdProvider) VerifyCICHash(ctx context.Context, idt []byte, expectedCICHash string) error {
-	return nil
-}
-
-func (m *MockOpenIdProvider) VerifyNonGQSig(ctx context.Context, idt []byte, expectedNonce string) error {
-	return nil
-}
diff --git a/examples/mfa/example.go b/examples/mfa/example.go
index a67feb9..00c56a9 100644
--- a/examples/mfa/example.go
+++ b/examples/mfa/example.go
@@ -24,6 +24,7 @@ import (
 	"github.com/openpubkey/openpubkey/client"
 	"github.com/openpubkey/openpubkey/client/providers"
 	"github.com/openpubkey/openpubkey/examples/mfa/mfacosigner"
+	"github.com/openpubkey/openpubkey/verifier"
 )
 
 var (
@@ -77,13 +78,13 @@ func main() {
 		fmt.Println("New PK token generated")
 
 		// Verify our pktoken including the cosigner signature
-		verifier := client.PKTokenVerifier{
-			AllowedProviders: []client.OpenIdProvider{provider},
-			AllowedCosigners: []client.CosignerProvider{cosignerProvider},
+		cosVerifier := verifier.NewCosignerVerifier(cosignerProvider.Issuer, verifier.CosignerVerifierOpts{})
+		verifier, err := verifier.New(provider.Verifier(), verifier.WithCosignerVerifiers(cosVerifier))
+		if err != nil {
+			fmt.Println(err)
+			return
 		}
-
-		// Verify our pktoken including the cosigner signature
-		if err := verifier.Verify(context.TODO(), pkt); err != nil {
+		if err := verifier.VerifyPKToken(context.TODO(), pkt); err != nil {
 			fmt.Println("Failed to verify PK token:", err)
 			os.Exit(1)
 		} else {
diff --git a/examples/simple/example.go b/examples/simple/example.go
index 3b37989..f0df938 100644
--- a/examples/simple/example.go
+++ b/examples/simple/example.go
@@ -24,6 +24,7 @@ import (
 	"github.com/openpubkey/openpubkey/client"
 	"github.com/openpubkey/openpubkey/client/providers"
 	"github.com/openpubkey/openpubkey/pktoken"
+	"github.com/openpubkey/openpubkey/verifier"
 )
 
 func Sign(op client.OpenIdProvider) ([]byte, []byte, error) {
@@ -67,7 +68,11 @@ func Verify(op client.OpenIdProvider, pktJson []byte, signedMsg []byte) error {
 	}
 
 	// Verify that PK Token is issued by the OP you wish to use
-	err = client.VerifyPKToken(context.Background(), pkt, op)
+	pktVerifier, err := verifier.New(op.Verifier())
+	if err != nil {
+		return err
+	}
+	err = pktVerifier.VerifyPKToken(context.Background(), pkt)
 	if err != nil {
 		return err
 	}
diff --git a/examples/ssh/sshcert/sshcert.go b/examples/ssh/sshcert/sshcert.go
index 8a7f290..8626cc7 100644
--- a/examples/ssh/sshcert/sshcert.go
+++ b/examples/ssh/sshcert/sshcert.go
@@ -12,6 +12,7 @@ import (
 	"github.com/openpubkey/openpubkey/client"
 	"github.com/openpubkey/openpubkey/pktoken"
 	"github.com/openpubkey/openpubkey/util"
+	"github.com/openpubkey/openpubkey/verifier"
 	"golang.org/x/crypto/ssh"
 )
 
@@ -109,10 +110,13 @@ func (s *SshCertSmuggler) VerifySshPktCert(op client.OpenIdProvider) (*pktoken.P
 		return nil, fmt.Errorf("openpubkey-pkt extension in cert failed deserialization: %w", err)
 	}
 
-	err = client.VerifyPKToken(context.Background(), pkt, op)
+	verifier, err := verifier.New(op.Verifier())
 	if err != nil {
 		return nil, err
 	}
+	if err := verifier.VerifyPKToken(context.TODO(), pkt); err != nil {
+		return nil, fmt.Errorf("failed to verify PK token: %w", err)
+	}
 
 	cic, err := pkt.GetCicValues()
 	if err != nil {
diff --git a/examples/x509/ca/ca.go b/examples/x509/ca/ca.go
index 6025f4d..f7e21ba 100644
--- a/examples/x509/ca/ca.go
+++ b/examples/x509/ca/ca.go
@@ -35,6 +35,7 @@ import (
 	"github.com/openpubkey/openpubkey/cert"
 	"github.com/openpubkey/openpubkey/client"
 	"github.com/openpubkey/openpubkey/pktoken"
+	"github.com/openpubkey/openpubkey/verifier"
 )
 
 type Ca struct {
@@ -106,13 +107,12 @@ func (a *Ca) CheckPKToken(pktJson []byte) (*pktoken.PKToken, error) {
 		return nil, err
 	}
 
-	if err := pkt.VerifyCicSig(); err != nil {
+	verifier, err := verifier.New(a.op.Verifier())
+	if err != nil {
 		return nil, err
 	}
-
-	err := client.VerifyPKToken(context.Background(), pkt, a.op)
-	if err != nil {
-		return nil, fmt.Errorf("error PK Token is not valid: %w", err)
+	if err := verifier.VerifyPKToken(context.TODO(), pkt); err != nil {
+		return nil, fmt.Errorf("failed to verify PK token: %w", err)
 	}
 
 	return pkt, nil
diff --git a/go.mod b/go.mod
index 61b82b1..dff8a5b 100644
--- a/go.mod
+++ b/go.mod
@@ -14,13 +14,15 @@ require (
 	golang.org/x/crypto v0.17.0
 )
 
+require github.com/stretchr/objx v0.5.0 // indirect
+
 require (
 	github.com/awnumar/memcall v0.1.2 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
 	github.com/fxamacker/cbor/v2 v2.4.0 // indirect
 	github.com/go-webauthn/x v0.1.4 // indirect
-	github.com/goccy/go-json v0.10.2 // indirect
+	github.com/goccy/go-json v0.10.2
 	github.com/golang-jwt/jwt/v5 v5.0.0 // indirect
 	github.com/golang/protobuf v1.5.3 // indirect
 	github.com/google/go-tpm v0.9.0 // indirect
diff --git a/go.sum b/go.sum
index 425dd8b..3108682 100644
--- a/go.sum
+++ b/go.sum
@@ -62,9 +62,13 @@ github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr
 github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
 github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
diff --git a/gq/gq.go b/gq/gq.go
index 8d6397c..9b7c5e4 100644
--- a/gq/gq.go
+++ b/gq/gq.go
@@ -56,6 +56,11 @@ type signerVerifier struct {
 	t int
 }
 
+// Creates a new SignerVerifier specifically for GQ256, meaning the security parameter is 256.
+func New256SignerVerifier(publicKey *rsa.PublicKey) (SignerVerifier, error) {
+	return NewSignerVerifier(publicKey, 256)
+}
+
 // NewSignerVerifier creates a SignerVerifier from the RSA public key of the trusted third-party which creates
 // the GQ1 private numbers.
 //
diff --git a/pktoken/cic.go b/pktoken/cic.go
deleted file mode 100644
index e1a1ff2..0000000
--- a/pktoken/cic.go
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright 2024 OpenPubkey
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//	http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-// SPDX-License-Identifier: Apache-2.0
-
-package pktoken
-
-import (
-	"context"
-
-	"github.com/lestrrat-go/jwx/v2/jws"
-	"github.com/openpubkey/openpubkey/pktoken/clientinstance"
-)
-
-func (p *PKToken) GetCicValues() (*clientinstance.Claims, error) {
-	cicPH, err := p.Cic.ProtectedHeaders().AsMap(context.TODO())
-	if err != nil {
-		return nil, err
-	}
-
-	return clientinstance.ParseClaims(cicPH)
-}
-
-func (p *PKToken) VerifyCicSig() error {
-	cic, err := p.GetCicValues()
-	if err != nil {
-		return err
-	}
-
-	_, err = jws.Verify(p.CicToken, jws.WithKey(cic.PublicKey().Algorithm(), cic.PublicKey()))
-	return err
-}
diff --git a/pktoken/cos.go b/pktoken/cos.go
index 87b1cb5..4d2e69a 100644
--- a/pktoken/cos.go
+++ b/pktoken/cos.go
@@ -17,17 +17,8 @@
 package pktoken
 
 import (
-	"context"
 	"encoding/json"
 	"fmt"
-	"net/http"
-	"time"
-
-	"github.com/lestrrat-go/jwx/v2/jwa"
-	"github.com/lestrrat-go/jwx/v2/jwk"
-	"github.com/lestrrat-go/jwx/v2/jws"
-	"github.com/openpubkey/openpubkey/util"
-	oidcclient "github.com/zitadel/oidc/v2/pkg/client"
 )
 
 type CosignerClaims struct {
@@ -43,7 +34,12 @@ type CosignerClaims struct {
 	Typ         string `json:"typ"`
 }
 
-func ParseCosignerClaims(protected []byte) (*CosignerClaims, error) {
+func (p *PKToken) ParseCosignerClaims() (*CosignerClaims, error) {
+	protected, err := json.Marshal(p.Cos.ProtectedHeaders())
+	if err != nil {
+		return nil, err
+	}
+
 	var claims CosignerClaims
 	if err := json.Unmarshal(protected, &claims); err != nil {
 		return nil, err
@@ -85,55 +81,3 @@ func ParseCosignerClaims(protected []byte) (*CosignerClaims, error) {
 
 	return &claims, nil
 }
-
-func (p *PKToken) VerifyCosSig() error {
-	if p.Cos == nil {
-		return fmt.Errorf("no cosigner signature")
-	}
-
-	cosToken, err := p.Compact(p.Cos)
-	if err != nil {
-		return err
-	}
-
-	// Parse our header
-	rawHeader, _, _, err := jws.SplitCompact(cosToken)
-	if err != nil {
-		return err
-	}
-	decodedHeader, err := util.Base64DecodeForJWT(rawHeader)
-	if err != nil {
-		return err
-	}
-	header, err := ParseCosignerClaims(decodedHeader)
-	if err != nil {
-		return err
-	}
-
-	// Check if it's expired
-	if time.Now().After(time.Unix(header.Expiration, 0)) {
-		return fmt.Errorf("cosigner signature expired")
-	}
-
-	discConf, err := oidcclient.Discover(header.Issuer, http.DefaultClient)
-	if err != nil {
-		return fmt.Errorf("failed to call OIDC discovery endpoint: %w", err)
-	}
-	set, err := jwk.Fetch(context.Background(), discConf.JwksURI)
-	if err != nil {
-		return fmt.Errorf("failed to fetch public keys from Cosigner JWKS endpoint: %w", err)
-	}
-
-	key, ok := set.LookupKeyID(header.KeyID)
-	if !ok {
-		return fmt.Errorf("missing key id (kid)")
-	}
-
-	if header.Algorithm != key.Algorithm().String() {
-		return fmt.Errorf("key (kid=%s) has alg (%s) which doesn't match alg (%s) in protected", key.KeyID(), key.Algorithm(), header.Algorithm)
-	}
-
-	_, err = jws.Verify(cosToken, jws.WithKey(jwa.KeyAlgorithmFrom(key.Algorithm()), key))
-
-	return err
-}
diff --git a/pktoken/gq.go b/pktoken/gq.go
deleted file mode 100644
index eb1fca6..0000000
--- a/pktoken/gq.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package pktoken
-
-import (
-	"crypto/rsa"
-	"fmt"
-
-	"github.com/openpubkey/openpubkey/gq"
-)
-
-func (p *PKToken) VerifyGQSig(pubKey *rsa.PublicKey, gqSecurityParameter int) error {
-	token, err := p.Compact(p.Op)
-	if err != nil {
-		return err
-	}
-
-	sv, err := gq.NewSignerVerifier(pubKey, gqSecurityParameter)
-	if err != nil {
-		return err
-	}
-	ok := sv.VerifyJWT(token)
-	if !ok {
-		return fmt.Errorf("error verifying OP GQ signature on PK Token (ID Token invalid)")
-	}
-	return nil
-}
diff --git a/pktoken/mocks/pktoken.go b/pktoken/mocks/pktoken.go
index 858c2b8..7749a84 100644
--- a/pktoken/mocks/pktoken.go
+++ b/pktoken/mocks/pktoken.go
@@ -19,19 +19,20 @@ package mocks
 import (
 	"context"
 	"crypto"
+	"testing"
 
 	"github.com/lestrrat-go/jwx/v2/jwa"
 	"github.com/lestrrat-go/jwx/v2/jwk"
-	"github.com/openpubkey/openpubkey/client/providers"
+	"github.com/openpubkey/openpubkey/client/mocks"
 	"github.com/openpubkey/openpubkey/pktoken"
 	"github.com/openpubkey/openpubkey/pktoken/clientinstance"
 )
 
-func GenerateMockPKToken(signingKey crypto.Signer, alg jwa.KeyAlgorithm) (*pktoken.PKToken, error) {
-	return GenerateMockPKTokenWithEmail(signingKey, alg, "")
+func GenerateMockPKToken(t *testing.T, signingKey crypto.Signer, alg jwa.KeyAlgorithm) (*pktoken.PKToken, error) {
+	return GenerateMockPKTokenWithEmail(t, signingKey, alg, "")
 }
 
-func GenerateMockPKTokenWithEmail(signingKey crypto.Signer, alg jwa.KeyAlgorithm, email string) (*pktoken.PKToken, error) {
+func GenerateMockPKTokenWithEmail(t *testing.T, signingKey crypto.Signer, alg jwa.KeyAlgorithm, email string) (*pktoken.PKToken, error) {
 
 	jwkKey, err := jwk.PublicKeyOf(signingKey)
 	if err != nil {
@@ -61,7 +62,7 @@ func GenerateMockPKTokenWithEmail(signingKey crypto.Signer, alg jwa.KeyAlgorithm
 	}
 
 	// Generate mock id token
-	op, err := providers.NewMockOpenIdProvider()
+	op, err := mocks.NewMockOpenIdProvider(t, map[string]any{})
 	if err != nil {
 		return nil, err
 	}
diff --git a/pktoken/pktoken.go b/pktoken/pktoken.go
index a1d7fdd..5799bd7 100644
--- a/pktoken/pktoken.go
+++ b/pktoken/pktoken.go
@@ -27,6 +27,7 @@ import (
 	"github.com/lestrrat-go/jwx/v2/jwk"
 	"github.com/lestrrat-go/jwx/v2/jws"
 
+	"github.com/openpubkey/openpubkey/pktoken/clientinstance"
 	"github.com/openpubkey/openpubkey/pktoken/simplejws"
 	"github.com/openpubkey/openpubkey/util"
 
@@ -97,6 +98,16 @@ func New(idToken []byte, cicToken []byte) (*PKToken, error) {
 	return pkt, nil
 }
 
+func (p *PKToken) Issuer() (string, error) {
+	var claims struct {
+		Issuer string `json:"iss"`
+	}
+	if err := json.Unmarshal(p.Payload, &claims); err != nil {
+		return "", fmt.Errorf("malformatted PK token claims: %w", err)
+	}
+	return claims.Issuer, nil
+}
+
 // Signs PK Token and then returns only the payload, header and signature as a JWT
 func (p *PKToken) SignToken(
 	signer crypto.Signer,
@@ -184,6 +195,15 @@ func (p *PKToken) ProviderAlgorithm() (jwa.SignatureAlgorithm, bool) {
 	return alg.(jwa.SignatureAlgorithm), true
 }
 
+func (p *PKToken) GetCicValues() (*clientinstance.Claims, error) {
+	cicPH, err := p.Cic.ProtectedHeaders().AsMap(context.TODO())
+	if err != nil {
+		return nil, err
+	}
+
+	return clientinstance.ParseClaims(cicPH)
+}
+
 // Deprecated: The PK Token now stores the signed tokens such as OpToken,
 // CicToken, etc.... removing the need for this function. Instead of
 // calling Compact, just get the token directly from the PK Token.
diff --git a/verifier/cosignerverifier.go b/verifier/cosignerverifier.go
new file mode 100644
index 0000000..59e9c05
--- /dev/null
+++ b/verifier/cosignerverifier.go
@@ -0,0 +1,100 @@
+package verifier
+
+import (
+	"context"
+	"crypto"
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/lestrrat-go/jwx/v2/jwa"
+	"github.com/lestrrat-go/jwx/v2/jwk"
+	"github.com/lestrrat-go/jwx/v2/jws"
+	"github.com/openpubkey/openpubkey/pktoken"
+	oidcclient "github.com/zitadel/oidc/v2/pkg/client"
+)
+
+type DefaultCosignerVerifier struct {
+	issuer  string
+	options CosignerVerifierOpts
+}
+
+type CosignerVerifierOpts struct {
+	// Strict specifies whether or not a pk token MUST contain a signature by this cosigner.
+	// Defaults to true.
+	Strict *bool
+	// Allows users to set custom function for discovering public key of Cosigner
+	DiscoverPublicKey func(ctx context.Context, kid string, issuer string) (crypto.PublicKey, error)
+}
+
+func NewCosignerVerifier(issuer string, options CosignerVerifierOpts) *DefaultCosignerVerifier {
+	v := &DefaultCosignerVerifier{
+		issuer:  issuer,
+		options: options,
+	}
+
+	// If no custom DiscoverPublicKey function is set, set default
+	if v.options.DiscoverPublicKey == nil {
+		v.options.DiscoverPublicKey = discoverCosignerPublicKey
+	}
+
+	// If strict is not set, then default it to true
+	if v.options.Strict == nil {
+		v.options.Strict = new(bool)
+		*v.options.Strict = true
+	}
+
+	return v
+}
+
+func (v *DefaultCosignerVerifier) Issuer() string {
+	return v.issuer
+}
+
+func (v *DefaultCosignerVerifier) Strict() bool {
+	return *v.options.Strict
+}
+
+func (v *DefaultCosignerVerifier) VerifyCosigner(ctx context.Context, pkt *pktoken.PKToken) error {
+	if pkt.Cos == nil {
+		return fmt.Errorf("no cosigner signature")
+	}
+
+	// Parse our header
+	header, err := pkt.ParseCosignerClaims()
+	if err != nil {
+		return err
+	}
+
+	key, err := v.options.DiscoverPublicKey(ctx, header.KeyID, header.Issuer)
+	if err != nil {
+		return err
+	}
+
+	// Check if it's expired
+	if time.Now().After(time.Unix(header.Expiration, 0)) {
+		return fmt.Errorf("cosigner signature expired")
+	}
+
+	_, err = jws.Verify(pkt.CosToken, jws.WithKey(jwa.KeyAlgorithmFrom(jwa.RS256), key))
+
+	return err
+}
+
+func discoverCosignerPublicKey(ctx context.Context, kid string, issuer string) (crypto.PublicKey, error) {
+	discConf, err := oidcclient.Discover(issuer, http.DefaultClient)
+	if err != nil {
+		return nil, fmt.Errorf("failed to call OIDC discovery endpoint: %w", err)
+	}
+	set, err := jwk.Fetch(context.Background(), discConf.JwksURI)
+	if err != nil {
+		return nil, fmt.Errorf("failed to fetch public keys from Cosigner JWKS endpoint: %w", err)
+	}
+
+	key, ok := set.LookupKeyID(kid)
+	if !ok {
+		return nil, fmt.Errorf("missing key id (kid)")
+	}
+
+	return key, nil
+}
diff --git a/verifier/providerverifier.go b/verifier/providerverifier.go
new file mode 100644
index 0000000..aed3337
--- /dev/null
+++ b/verifier/providerverifier.go
@@ -0,0 +1,263 @@
+package verifier
+
+import (
+	"context"
+	"crypto"
+	"crypto/rsa"
+	"encoding/json"
+	"fmt"
+
+	"github.com/lestrrat-go/jwx/v2/jwa"
+	"github.com/lestrrat-go/jwx/v2/jws"
+	"github.com/openpubkey/openpubkey/client/providers/discover"
+	"github.com/openpubkey/openpubkey/gq"
+	"github.com/openpubkey/openpubkey/pktoken"
+	"github.com/openpubkey/openpubkey/util"
+)
+
+type DefaultProviderVerifier struct {
+	issuer          string
+	commitmentClaim string
+	options         ProviderVerifierOpts
+}
+
+type ProviderVerifierOpts struct {
+	// If ClientID is specified, then verification will require that the ClientID
+	// be present in the audience ("aud") claim of the PK token payload
+	ClientID string
+	// Specifies whether to skip the Client ID check, defaults to false
+	SkipClientIDCheck bool
+	// Custom function for discovering public key of Provider
+	DiscoverPublicKey func(ctx context.Context, headers jws.Headers, issuer string) (crypto.PublicKey, error)
+	// Allows for successful verification of expired tokens
+	SkipExpirationCheck bool
+	// Only allows GQ signatures, a provider signature under any other algorithm
+	// is seen as an error
+	GQOnly bool
+}
+
+// Creates a new ProviderVerifier with required fields
+//
+// issuer: Is the OpenID provider issuer as seen in ID token e.g. "https://accounts.google.com"
+// commitmentClaim: the ID token payload claim name where the cicHash was stored during issuance
+func NewProviderVerifier(issuer, commitmentClaim string, options ProviderVerifierOpts) *DefaultProviderVerifier {
+	v := &DefaultProviderVerifier{
+		issuer:          issuer,
+		commitmentClaim: commitmentClaim,
+		options:         options,
+	}
+
+	// If no custom DiscoverPublicKey function is set, set default
+	if v.options.DiscoverPublicKey == nil {
+		v.options.DiscoverPublicKey = discover.ProviderPublicKey
+	}
+
+	return v
+}
+
+func (v *DefaultProviderVerifier) Issuer() string {
+	return v.issuer
+}
+
+func (v *DefaultProviderVerifier) VerifyProvider(ctx context.Context, pkt *pktoken.PKToken) error {
+	// Check whether Audience claim matches provided Client ID
+	// No error is thrown if option is set to skip client ID check
+	if err := verifyAudience(pkt, v.options.ClientID); err != nil && !v.options.SkipClientIDCheck {
+		return err
+	}
+
+	alg, ok := pkt.ProviderAlgorithm()
+	if !ok {
+		return fmt.Errorf("provider algorithm type missing")
+	}
+
+	if alg != gq.GQ256 && v.options.GQOnly {
+		return ErrNonGQUnsupported
+	}
+
+	switch alg {
+	case gq.GQ256:
+		if err := VerifyGQSig(ctx, pkt); err != nil {
+			return fmt.Errorf("error verifying OP GQ signature on PK Token: %w", err)
+		}
+	case jwa.RS256:
+		pubKey, err := v.providerPublicKey(ctx, pkt)
+		if err != nil {
+			return fmt.Errorf("failed to get OP public key: %w", err)
+		}
+
+		if _, err := jws.Verify(pkt.OpToken, jws.WithKey(alg, pubKey)); err != nil {
+			return err
+		}
+	}
+
+	if err := v.verifyCommitment(pkt); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// This function takes in an OIDC Provider created ID token or GQ-signed modification of one and returns
+// the associated public key
+func (v *DefaultProviderVerifier) providerPublicKey(ctx context.Context, pkt *pktoken.PKToken) (crypto.PublicKey, error) {
+	// a JWT is guaranteed to have exactly one signature
+	headers := pkt.Op.ProtectedHeaders()
+
+	alg, ok := headers.Get(jws.AlgorithmKey)
+	if !ok {
+		return nil, fmt.Errorf("missing algorithm header")
+	}
+
+	var providerHeaders jws.Headers
+	if alg == gq.GQ256 {
+		origHeaders, err := originalTokenHeaders(pkt.OpToken)
+		if err != nil {
+			return nil, fmt.Errorf("malformatted PK token headers: %w", err)
+		}
+
+		if origHeaders.Algorithm() != jwa.RS256 {
+			return nil, fmt.Errorf("expected original headers to contain RS256 alg, got %s", headers.Algorithm())
+		}
+
+		providerHeaders = origHeaders
+	} else {
+		providerHeaders = headers
+	}
+
+	// Extract our issuer from the payload claims
+	var claims struct {
+		Issuer string `json:"iss"`
+	}
+	if err := json.Unmarshal(pkt.Payload, &claims); err != nil {
+		return nil, err
+	}
+
+	return v.options.DiscoverPublicKey(ctx, providerHeaders, claims.Issuer)
+}
+
+func (v *DefaultProviderVerifier) verifyCommitment(pkt *pktoken.PKToken) error {
+	var claims map[string]any
+	if err := json.Unmarshal(pkt.Payload, &claims); err != nil {
+		return err
+	}
+
+	cic, err := pkt.GetCicValues()
+	if err != nil {
+		return err
+	}
+	expectedCommitment, err := cic.Hash()
+	if err != nil {
+		return err
+	}
+	commitment, ok := claims[v.commitmentClaim]
+	if !ok {
+		return fmt.Errorf("missing commitment claim %s", v.commitmentClaim)
+	}
+
+	if commitment != string(expectedCommitment) {
+		return fmt.Errorf("nonce claim doesn't match, got %q, expected %s", commitment, string(expectedCommitment))
+	}
+	return nil
+}
+
+func VerifyGQSig(ctx context.Context, pkt *pktoken.PKToken) error {
+	alg, ok := pkt.ProviderAlgorithm()
+	if !ok {
+		return fmt.Errorf("missing provider algorithm header")
+	}
+
+	if alg != gq.GQ256 {
+		return fmt.Errorf("signature is not of type GQ")
+	}
+
+	opToken, err := pkt.Compact(pkt.Op)
+	if err != nil {
+		return err
+	}
+
+	origHeaders, err := originalTokenHeaders(opToken)
+	if err != nil {
+		return fmt.Errorf("malformatted PK token headers: %w", err)
+	}
+
+	alg = origHeaders.Algorithm()
+	if alg != jwa.RS256 {
+		return fmt.Errorf("expected original headers to contain RS256 alg, got %s", alg)
+	}
+
+	issuer, err := pkt.Issuer()
+	if err != nil {
+		return fmt.Errorf("missing issuer: %w", err)
+	}
+
+	jwkKey, err := discover.ProviderPublicKey(ctx, origHeaders, issuer)
+	if err != nil {
+		return fmt.Errorf("failed to get provider public key: %w", err)
+	}
+
+	token, err := pkt.Compact(pkt.Op)
+	if err != nil {
+		return err
+	}
+
+	rsaKey, ok := jwkKey.(*rsa.PublicKey)
+	if !ok {
+		return fmt.Errorf("jwk is not an RSA key")
+	}
+	sv, err := gq.New256SignerVerifier(rsaKey)
+	if err != nil {
+		return err
+	}
+	ok = sv.VerifyJWT(token)
+	if !ok {
+		return fmt.Errorf("error verifying OP GQ signature on PK Token (ID Token invalid)")
+	}
+	return nil
+}
+
+func originalTokenHeaders(token []byte) (jws.Headers, error) {
+	origHeadersB64, err := gq.OriginalJWTHeaders(token)
+	if err != nil {
+		return nil, fmt.Errorf("malformatted PK token headers: %w", err)
+	}
+
+	origHeaders, err := util.Base64DecodeForJWT(origHeadersB64)
+	if err != nil {
+		return nil, fmt.Errorf("error decoding original token headers: %w", err)
+	}
+
+	headers := jws.NewHeaders()
+	err = json.Unmarshal(origHeaders, &headers)
+	if err != nil {
+		return nil, fmt.Errorf("error parsing segment: %w", err)
+	}
+
+	return headers, nil
+}
+
+func verifyAudience(pkt *pktoken.PKToken, clientID string) error {
+	var claims struct {
+		Audience any `json:"aud"`
+	}
+	if err := json.Unmarshal(pkt.Payload, &claims); err != nil {
+		return err
+	}
+
+	switch aud := claims.Audience.(type) {
+	case string:
+		if aud != clientID {
+			return fmt.Errorf("audience does not contain clientID %s, aud = %s", clientID, aud)
+		}
+	case []any:
+		for _, audience := range aud {
+			if audience.(string) == clientID {
+				return nil
+			}
+		}
+		return fmt.Errorf("audience does not contain clientID %s, aud = %v", clientID, aud)
+	default:
+		return fmt.Errorf("missing audience claim")
+	}
+	return nil
+}
diff --git a/verifier/verifier.go b/verifier/verifier.go
new file mode 100644
index 0000000..85bc48d
--- /dev/null
+++ b/verifier/verifier.go
@@ -0,0 +1,168 @@
+package verifier
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/lestrrat-go/jwx/v2/jws"
+	"github.com/openpubkey/openpubkey/gq"
+	"github.com/openpubkey/openpubkey/pktoken"
+)
+
+var ErrNonGQUnsupported = fmt.Errorf("non-GQ signatures are not supported")
+
+type ProviderVerifier interface {
+	// Returns the OpenID provider issuer as seen in ID token e.g. "https://accounts.google.com"
+	Issuer() string
+	VerifyProvider(ctx context.Context, pkt *pktoken.PKToken) error
+}
+
+type CosignerVerifier interface {
+	Issuer() string
+	Strict() bool // Whether or not a given cosigner MUST be present for successful verification
+	VerifyCosigner(ctx context.Context, pkt *pktoken.PKToken) error
+}
+
+type VerifierOpts func(*Verifier) error
+
+func WithCosignerVerifiers(verifiers ...*DefaultCosignerVerifier) VerifierOpts {
+	return func(v *Verifier) error {
+		for _, verifier := range verifiers {
+			if _, ok := v.cosigners[verifier.Issuer()]; ok {
+				return fmt.Errorf("cosigner verifier found with duplicate issuer: %s", verifier.Issuer())
+			}
+			v.cosigners[verifier.issuer] = verifier
+		}
+		return nil
+	}
+}
+
+func AddProviderVerifiers(verifiers ...ProviderVerifier) VerifierOpts {
+	return func(v *Verifier) error {
+		for _, verifier := range verifiers {
+			if _, ok := v.providers[verifier.Issuer()]; ok {
+				return fmt.Errorf("provider verifier found with duplicate issuer: %s", verifier.Issuer())
+			}
+			v.providers[verifier.Issuer()] = verifier
+		}
+		return nil
+	}
+}
+
+type Check func(*Verifier, *pktoken.PKToken) error
+
+func GQOnly() Check {
+	return func(_ *Verifier, pkt *pktoken.PKToken) error {
+		alg, ok := pkt.ProviderAlgorithm()
+		if !ok {
+			return fmt.Errorf("missing provider algorithm header")
+		}
+
+		if alg != gq.GQ256 {
+			return ErrNonGQUnsupported
+		}
+		return nil
+	}
+}
+
+type Verifier struct {
+	providers map[string]ProviderVerifier
+	cosigners map[string]CosignerVerifier
+}
+
+func New(verifier ProviderVerifier, options ...VerifierOpts) (*Verifier, error) {
+	v := &Verifier{
+		providers: map[string]ProviderVerifier{
+			verifier.Issuer(): verifier,
+		},
+		cosigners: map[string]CosignerVerifier{},
+	}
+
+	for _, option := range options {
+		if err := option(v); err != nil {
+			return nil, err
+		}
+	}
+
+	return v, nil
+}
+
+// Verifies whether a PK token is valid and matches all expected claims.
+//
+// extraChecks: Allows for optional specification of additional checks
+func (v *Verifier) VerifyPKToken(
+	ctx context.Context,
+	pkt *pktoken.PKToken,
+	extraChecks ...Check,
+) error {
+	issuer, err := pkt.Issuer()
+	if err != nil {
+		return err
+	}
+
+	providerVerfier, ok := v.providers[issuer]
+	if !ok {
+		return fmt.Errorf("unrecognized issuer: %s", issuer)
+	}
+
+	if err := providerVerfier.VerifyProvider(ctx, pkt); err != nil {
+		return err
+	}
+
+	if len(v.cosigners) > 0 {
+		if pkt.Cos == nil {
+			// If there's no cosigner signature and any provided cosigner verifiers are strict, then return error
+			for _, cosignerVerifier := range v.cosigners {
+				if cosignerVerifier.Strict() {
+					return fmt.Errorf("missing required cosigner signature by %s", cosignerVerifier.Issuer())
+				}
+			}
+		} else {
+			cosignerClaims, err := pkt.ParseCosignerClaims()
+			if err != nil {
+				return err
+			}
+
+			cosignerVerifier, ok := v.cosigners[cosignerClaims.Issuer]
+			if !ok {
+				// If other cosigners are present, do we accept?
+				return fmt.Errorf("unrecognized cosigner %s", cosignerClaims.Issuer)
+			}
+
+			// Verify cosigner signature
+			if err := cosignerVerifier.VerifyCosigner(ctx, pkt); err != nil {
+				return err
+			}
+
+			// If any other cosigner verifiers are set to strict but aren't present, then return error
+			for _, cosignerVerifier := range v.cosigners {
+				if cosignerVerifier.Strict() && cosignerVerifier.Issuer() != cosignerClaims.Issuer {
+					return fmt.Errorf("missing required cosigner signature by %s", cosignerVerifier.Issuer())
+				}
+			}
+		}
+	}
+
+	if err := VerifyCicSignature(pkt); err != nil {
+		return fmt.Errorf("error verifying client signature on PK Token: %w", err)
+	}
+
+	// Cycles through any provided additional checks and returns the first error, if any.
+	for _, check := range extraChecks {
+		if err := check(v, pkt); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func VerifyCicSignature(pkt *pktoken.PKToken) error {
+	cic, err := pkt.GetCicValues()
+	if err != nil {
+		return err
+	}
+
+	_, err = jws.Verify(pkt.CicToken, jws.WithKey(cic.PublicKey().Algorithm(), cic.PublicKey()))
+	return err
+}


Test patch:
diff --git a/cert/cert_test.go b/cert/cert_test.go
index fe89f05..2bdd3fc 100644
--- a/cert/cert_test.go
+++ b/cert/cert_test.go
@@ -25,7 +25,7 @@ import (
 
 	"github.com/lestrrat-go/jwx/v2/jwa"
 	"github.com/openpubkey/openpubkey/client"
-	"github.com/openpubkey/openpubkey/client/providers"
+	"github.com/openpubkey/openpubkey/client/mocks"
 	"github.com/openpubkey/openpubkey/util"
 	"github.com/stretchr/testify/require"
 )
@@ -37,7 +37,7 @@ func TestCreateX509Cert(t *testing.T) {
 	signer, err := util.GenKeyPair(alg)
 	require.NoError(t, err)
 
-	op, err := providers.NewMockOpenIdProvider()
+	op, err := mocks.NewMockOpenIdProvider(t, map[string]any{})
 	require.NoError(t, err)
 
 	opkClient, err := client.New(op, client.WithSigner(signer, alg), client.WithSignGQ(true))
diff --git a/client/opkclient_test.go b/client/opkclient_test.go
index ace4e93..ce2ef0e 100644
--- a/client/opkclient_test.go
+++ b/client/opkclient_test.go
@@ -26,7 +26,7 @@ import (
 	"github.com/lestrrat-go/jwx/v2/jwa"
 	"github.com/lestrrat-go/jwx/v2/jwk"
 	"github.com/openpubkey/openpubkey/client"
-	"github.com/openpubkey/openpubkey/client/providers"
+	"github.com/openpubkey/openpubkey/client/mocks"
 	"github.com/openpubkey/openpubkey/gq"
 	"github.com/openpubkey/openpubkey/pktoken"
 	"github.com/openpubkey/openpubkey/util"
@@ -38,31 +38,33 @@ func TestClient(t *testing.T) {
 		name        string
 		gq          bool
 		signer      bool
-		alg         jwa.KeyAlgorithm
+		signerAlg   jwa.KeyAlgorithm
 		extraClaims map[string]string
 	}{
 		{name: "without GQ", gq: false, signer: false},
 		{name: "with GQ", gq: true, signer: false},
-		{name: "with GQ, with signer", gq: true, signer: true, alg: jwa.RS256},
-		{name: "with GQ, with signer, with empty extraClaims ", gq: true, signer: true, alg: jwa.ES256, extraClaims: map[string]string{}},
-		{name: "with GQ, with signer, with extraClaims", gq: true, signer: true, alg: jwa.ES256, extraClaims: map[string]string{"extra": "yes"}},
+		{name: "with GQ, with signer", gq: true, signer: true, signerAlg: jwa.RS256},
+		{name: "with GQ, with signer, with empty extraClaims", gq: true, signer: true, signerAlg: jwa.ES256, extraClaims: map[string]string{}},
+		{name: "with GQ, with signer, with extraClaims", gq: true, signer: true, signerAlg: jwa.ES256, extraClaims: map[string]string{"extra": "yes"}},
 		{name: "with GQ, with extraClaims", gq: true, signer: false, extraClaims: map[string]string{"extra": "yes", "aaa": "bbb"}},
 	}
 
-	op, err := providers.NewMockOpenIdProvider()
-	require.NoError(t, err, "failed to create mock OpenIdProvider")
+	op, err := mocks.NewMockOpenIdProvider(t, map[string]any{})
+	if err != nil {
+		t.Fatal(err)
+	}
 
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
 
 			var c *client.OpkClient
 			if tc.signer {
-				signer, err := util.GenKeyPair(tc.alg)
+				signer, err := util.GenKeyPair(tc.signerAlg)
 				require.NoError(t, err, tc.name)
-				c, err = client.New(op, client.WithSignGQ(tc.gq), client.WithSigner(signer, tc.alg))
+				c, err = client.New(op, client.WithSignGQ(tc.gq), client.WithSigner(signer, tc.signerAlg))
 				require.NoError(t, err, tc.name)
 				require.Equal(t, signer, c.GetSigner(), tc.name)
-				require.Equal(t, tc.alg, c.GetAlg(), tc.name)
+				require.Equal(t, tc.signerAlg, c.GetAlg(), tc.name)
 			} else if tc.gq {
 				c, err = client.New(op, client.WithSignGQ(tc.gq))
 				require.NoError(t, err, tc.name)
@@ -103,7 +105,7 @@ func TestClient(t *testing.T) {
 				t.Fatalf("expected jkt header to be a string, got %T", jkt)
 			}
 
-			pubkey, err := op.PublicKey(context.Background(), nil)
+			pubkey, err := op.PublicKey(context.Background(), pkt.Op.PublicHeaders())
 			require.NoError(t, err, tc.name)
 
 			pub, err := jwk.FromRaw(pubkey)
@@ -115,31 +117,34 @@ func TestClient(t *testing.T) {
 			thumbprintStr := string(util.Base64EncodeForJWT(thumbprint))
 			require.Equal(t, jktstr, thumbprintStr, "jkt header does not match op thumbprint in "+tc.name)
 
-			alg, ok := pkt.ProviderAlgorithm()
+			providerAlg, ok := pkt.ProviderAlgorithm()
 			if !ok {
 				t.Fatal(fmt.Errorf("missing algorithm"))
 			}
 
 			if tc.gq {
-				require.Equal(t, gq.GQ256, alg, tc.name)
+				require.Equal(t, gq.GQ256, providerAlg, tc.name)
 
 				// Verify our GQ signature
 				idt, err := pkt.Compact(pkt.Op)
 				require.NoError(t, err, tc.name)
 
-				opPubKey, err := op.PublicKey(context.Background(), nil)
+				opPubKey, err := op.PublicKey(context.Background(), pkt.Op.PublicHeaders())
 				require.NoError(t, err, tc.name)
 
-				sv, err := gq.NewSignerVerifier(opPubKey.(*rsa.PublicKey), client.GQSecurityParameter)
+				rsaKey, ok := opPubKey.(*rsa.PublicKey)
+				require.Equal(t, true, ok)
+
+				sv, err := gq.New256SignerVerifier(rsaKey)
 				require.NoError(t, err, tc.name)
 
-				ok := sv.VerifyJWT(idt)
+				ok = sv.VerifyJWT(idt)
 				if !ok {
 					t.Fatal(fmt.Errorf("error verifying OP GQ signature on PK Token (ID Token invalid)"))
 				}
 			} else {
 				// Expect alg to be RS256 alg when not signing with GQ
-				require.Equal(t, jwa.RS256, alg, tc.name)
+				require.Equal(t, jwa.RS256, providerAlg, tc.name)
 			}
 		})
 	}
diff --git a/cosigner/authcosigner_test.go b/cosigner/authcosigner_test.go
index 9bcd30f..4588a37 100644
--- a/cosigner/authcosigner_test.go
+++ b/cosigner/authcosigner_test.go
@@ -39,7 +39,7 @@ func TestInitAuth(t *testing.T) {
 	signer, err := util.GenKeyPair(alg)
 	require.NoError(t, err, "failed to generate key pair")
 
-	pkt, err := mocks.GenerateMockPKToken(signer, alg)
+	pkt, err := mocks.GenerateMockPKToken(t, signer, alg)
 	require.NoError(t, err, "failed to generate mock PK Token")
 
 	cosP := client.CosignerProvider{
@@ -67,7 +67,7 @@ func TestRedeemAuthcode(t *testing.T) {
 	signer, err := util.GenKeyPair(alg)
 	require.NoError(t, err, "failed to generate key pair")
 
-	pkt, err := mocks.GenerateMockPKToken(signer, alg)
+	pkt, err := mocks.GenerateMockPKToken(t, signer, alg)
 	require.NoError(t, err, "failed to generate mock PK Token")
 
 	cosP := client.CosignerProvider{
@@ -79,7 +79,7 @@ func TestRedeemAuthcode(t *testing.T) {
 	diffSigner, err := util.GenKeyPair(alg)
 	require.NoError(t, err, "failed to generate key pair")
 
-	diffPkt, err := mocks.GenerateMockPKToken(diffSigner, alg)
+	diffPkt, err := mocks.GenerateMockPKToken(t, diffSigner, alg)
 	require.NoError(t, err, "failed to generate mock PK Token")
 
 	tests := []struct {
@@ -118,7 +118,7 @@ func TestRedeemAuthcode(t *testing.T) {
 func TestCanOnlyRedeemAuthcodeOnce(t *testing.T) {
 	alg := jwa.ES256
 	signer, err := util.GenKeyPair(alg)
-	pkt, err := mocks.GenerateMockPKToken(signer, alg)
+	pkt, err := mocks.GenerateMockPKToken(t, signer, alg)
 	require.NoError(t, err, "failed to generate mock PK Token")
 
 	cos := CreateAuthCosigner(t)
diff --git a/cosigner/cosigner_test.go b/cosigner/cosigner_test.go
index 6b39fba..90c605e 100644
--- a/cosigner/cosigner_test.go
+++ b/cosigner/cosigner_test.go
@@ -41,7 +41,7 @@ func TestSimpleCosigner(t *testing.T) {
 	}
 
 	email := "arthur.aardvark@example.com"
-	pkt, err := mocks.GenerateMockPKTokenWithEmail(signer, alg, email)
+	pkt, err := mocks.GenerateMockPKTokenWithEmail(t, signer, alg, email)
 	require.NoError(t, err, "failed to generate key pair")
 
 	cosignerClaims := pktoken.CosignerClaims{
diff --git a/examples/mfa/mfacosigner/mfacosigner_test.go b/examples/mfa/mfacosigner/mfacosigner_test.go
index 7c681d3..308f63d 100644
--- a/examples/mfa/mfacosigner/mfacosigner_test.go
+++ b/examples/mfa/mfacosigner/mfacosigner_test.go
@@ -39,7 +39,7 @@ func TestFullFlow(t *testing.T) {
 	require.NoError(t, err)
 
 	email := "arthur.aardvark@example.com"
-	pkt, err := mocks.GenerateMockPKTokenWithEmail(signer, alg, email)
+	pkt, err := mocks.GenerateMockPKTokenWithEmail(t, signer, alg, email)
 	require.NoError(t, err)
 
 	// Create our MFA Cosigner
@@ -125,7 +125,7 @@ func TestBadCosSigTyp(t *testing.T) {
 	require.NoError(t, err)
 
 	email := "arthur.aardvark@example.com"
-	pkt, err := mocks.GenerateMockPKTokenWithEmail(signer, alg, email)
+	pkt, err := mocks.GenerateMockPKTokenWithEmail(t, signer, alg, email)
 	require.NoError(t, err)
 
 	// Create our MFA Cosigner
diff --git a/examples/ssh/opkssh/cli_test.go b/examples/ssh/opkssh/cli_test.go
index e5d2cd0..2ae6c6a 100644
--- a/examples/ssh/opkssh/cli_test.go
+++ b/examples/ssh/opkssh/cli_test.go
@@ -1,14 +1,17 @@
 package main
 
 import (
+	"context"
 	"strings"
 	"testing"
 
 	"github.com/lestrrat-go/jwx/v2/jwa"
-	"github.com/openpubkey/openpubkey/client/providers"
+	"github.com/openpubkey/openpubkey/client"
+	clientmock "github.com/openpubkey/openpubkey/client/mocks"
 	"github.com/openpubkey/openpubkey/examples/ssh/sshcert"
 	"github.com/openpubkey/openpubkey/pktoken"
-	"github.com/openpubkey/openpubkey/pktoken/mocks"
+	"github.com/stretchr/testify/require"
+
 	"github.com/openpubkey/openpubkey/util"
 	"golang.org/x/crypto/ssh"
 )
@@ -18,24 +21,30 @@ func AllowAllPolicyEnforcer(userDesired string, pkt *pktoken.PKToken) error {
 }
 
 func TestAuthorizedKeysCommand(t *testing.T) {
-	op, err := providers.NewMockOpenIdProvider()
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	principals := []string{"guest", "dev"}
 	alg := jwa.ES256
-
 	signer, err := util.GenKeyPair(alg)
 	if err != nil {
 		t.Fatal(err)
 	}
-	email := "arthur.aardvark@example.com"
-	pkt, err := mocks.GenerateMockPKTokenWithEmail(signer, alg, email)
+
+	// extra ID token payload claims
+	mockEmail := "arthur.aardvark@example.com"
+	extraClaims := map[string]any{
+		"email": mockEmail,
+	}
+
+	op, err := clientmock.NewMockOpenIdProvider(t, extraClaims)
 	if err != nil {
 		t.Fatal(err)
 	}
 
+	client, err := client.New(op, client.WithSigner(signer, alg))
+	require.NoError(t, err)
+
+	pkt, err := client.Auth(context.Background())
+	require.NoError(t, err)
+
+	principals := []string{"guest", "dev"}
 	cert, err := sshcert.New(pkt, principals)
 	if err != nil {
 		t.Error(err)
diff --git a/examples/ssh/sshcert/sshcert_test.go b/examples/ssh/sshcert/sshcert_test.go
index 25cfc98..d0891df 100644
--- a/examples/ssh/sshcert/sshcert_test.go
+++ b/examples/ssh/sshcert/sshcert_test.go
@@ -1,6 +1,7 @@
 package sshcert
 
 import (
+	"context"
 	"crypto/rand"
 	"encoding/json"
 	"fmt"
@@ -9,9 +10,11 @@ import (
 
 	"github.com/lestrrat-go/jwx/v2/jwa"
 	"github.com/lestrrat-go/jwx/v2/jwk"
+	"github.com/openpubkey/openpubkey/client"
+	clientmock "github.com/openpubkey/openpubkey/client/mocks"
 	"github.com/openpubkey/openpubkey/pktoken"
-	"github.com/openpubkey/openpubkey/pktoken/mocks"
 	"github.com/openpubkey/openpubkey/util"
+	"github.com/stretchr/testify/require"
 	"golang.org/x/crypto/ssh"
 )
 
@@ -84,30 +87,38 @@ func TestCASignerCreation(t *testing.T) {
 }
 
 func TestSshCertCreation(t *testing.T) {
-	caSigner, err := newSshSignerFromPem(caSecretKey)
-	if err != nil {
-		t.Error(err)
-	}
-
-	principals := []string{"guest", "dev"}
-
 	alg := jwa.ES256
-
 	signingKey, err := util.GenKeyPair(alg)
 	if err != nil {
 		t.Fatal(err)
 	}
-	email := "arthur.aardvark@example.com"
-	pkt, err := mocks.GenerateMockPKTokenWithEmail(signingKey, alg, email)
-	if err != nil {
-		t.Fatal(err)
+
+	// extra ID token payload claims
+	mockEmail := "arthur.aardvark@example.com"
+	extraClaims := map[string]any{
+		"email": mockEmail,
 	}
 
+	op, err := clientmock.NewMockOpenIdProvider(t, extraClaims)
+	require.NoError(t, err)
+
+	client, err := client.New(op, client.WithSigner(signingKey, alg))
+	require.NoError(t, err)
+
+	pkt, err := client.Auth(context.Background())
+	require.NoError(t, err)
+
+	principals := []string{"guest", "dev"}
 	cert, err := New(pkt, principals)
 	if err != nil {
 		t.Error(err)
 	}
 
+	caSigner, err := newSshSignerFromPem(caSecretKey)
+	if err != nil {
+		t.Error(err)
+	}
+
 	sshCert, err := cert.SignCert(caSigner)
 	if err != nil {
 		t.Error(err)
@@ -122,8 +133,8 @@ func TestSshCertCreation(t *testing.T) {
 		t.Error(err)
 	}
 
-	if sshCert.KeyId != email {
-		t.Error(fmt.Errorf("expected KeyId to be (%s) but was (%s)", email, sshCert.KeyId))
+	if sshCert.KeyId != mockEmail {
+		t.Error(fmt.Errorf("expected KeyId to be (%s) but was (%s)", mockEmail, sshCert.KeyId))
 	}
 
 	pktB64, ok := sshCert.Extensions["openpubkey-pkt"]
@@ -131,6 +142,9 @@ func TestSshCertCreation(t *testing.T) {
 		t.Error(err)
 	}
 	pktExtJson, err := util.Base64DecodeForJWT([]byte(pktB64))
+	if err != nil {
+		t.Error(err)
+	}
 
 	var pktExt *pktoken.PKToken
 	err = json.Unmarshal(pktExtJson, &pktExt)
diff --git a/examples/x509/ca/ca_test.go b/examples/x509/ca/ca_test.go
index caf081c..0767d56 100644
--- a/examples/x509/ca/ca_test.go
+++ b/examples/x509/ca/ca_test.go
@@ -17,6 +17,7 @@
 package ca
 
 import (
+	"context"
 	"crypto/ecdsa"
 	"crypto/x509"
 	"encoding/json"
@@ -27,13 +28,13 @@ import (
 	"github.com/lestrrat-go/jwx/v2/jws"
 	"github.com/stretchr/testify/require"
 
-	"github.com/openpubkey/openpubkey/client/providers"
-	"github.com/openpubkey/openpubkey/pktoken/mocks"
+	"github.com/openpubkey/openpubkey/client"
+	clientmock "github.com/openpubkey/openpubkey/client/mocks"
 	"github.com/openpubkey/openpubkey/util"
 )
 
 func TestCACertCreation(t *testing.T) {
-	op, err := providers.NewMockOpenIdProvider()
+	op, err := clientmock.NewMockOpenIdProvider(t, map[string]any{})
 	require.NoError(t, err)
 
 	certAuth, err := New(op)
@@ -45,8 +46,13 @@ func TestCACertCreation(t *testing.T) {
 	userAlg := jwa.ES256
 	userSigningKey, err := util.GenKeyPair(userAlg)
 	require.NoError(t, err)
-	pkt, err := mocks.GenerateMockPKToken(userSigningKey, userAlg)
+
+	client, err := client.New(op, client.WithSigner(userSigningKey, userAlg))
+	require.NoError(t, err)
+
+	pkt, err := client.Auth(context.Background())
 	require.NoError(t, err)
+
 	pktJson, err := json.Marshal(pkt)
 	require.NoError(t, err)
 
diff --git a/gq/gq_test.go b/gq/gq_test.go
index 4eb7dfe..569d48c 100644
--- a/gq/gq_test.go
+++ b/gq/gq_test.go
@@ -155,7 +155,7 @@ func createOIDCToken(oidcPrivKey *rsa.PrivateKey, audience string) ([]byte, erro
 		return nil, err
 	}
 
-	jwt, err := jws.Sign(
+	return jws.Sign(
 		payloadBytes,
 		jws.WithKey(
 			alg,
@@ -163,9 +163,4 @@ func createOIDCToken(oidcPrivKey *rsa.PrivateKey, audience string) ([]byte, erro
 			jws.WithProtectedHeaders(oidcHeader),
 		),
 	)
-	if err != nil {
-		return nil, err
-	}
-
-	return jwt, nil
 }
diff --git a/pktoken/pktoken_test.go b/pktoken/pktoken_test.go
index 120e651..87ec76b 100644
--- a/pktoken/pktoken_test.go
+++ b/pktoken/pktoken_test.go
@@ -40,7 +40,7 @@ func TestPkToken(t *testing.T) {
 	signingKey, err := util.GenKeyPair(alg)
 	require.NoError(t, err)
 
-	pkt, err := mocks.GenerateMockPKToken(signingKey, alg)
+	pkt, err := mocks.GenerateMockPKToken(t, signingKey, alg)
 	require.NoError(t, err)
 
 	testPkTokenMessageSigning(t, pkt, signingKey)
@@ -216,7 +216,7 @@ func TestThumprintCalculation(t *testing.T) {
 
 }
 
-func TestJktInPublicHEader(t *testing.T) {
+func TestJktInPublicHeader(t *testing.T) {
 	fromRfc := "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"
 
 	// Add create thumbprint
@@ -232,7 +232,7 @@ func TestJktInPublicHEader(t *testing.T) {
 	signingKey, err := util.GenKeyPair(alg)
 	require.NoError(t, err)
 
-	pkt, err := mocks.GenerateMockPKToken(signingKey, alg)
+	pkt, err := mocks.GenerateMockPKToken(t, signingKey, alg)
 	require.NoError(t, err)
 
 	// Add to public header
diff --git a/verifier/verifier_test.go b/verifier/verifier_test.go
new file mode 100644
index 0000000..929fcc6
--- /dev/null
+++ b/verifier/verifier_test.go
@@ -0,0 +1,140 @@
+package verifier_test
+
+import (
+	"context"
+	"crypto"
+	"crypto/rand"
+	"crypto/rsa"
+	"testing"
+
+	"github.com/lestrrat-go/jwx/v2/jwa"
+	"github.com/lestrrat-go/jwx/v2/jwk"
+	"github.com/lestrrat-go/jwx/v2/jws"
+	"github.com/openpubkey/openpubkey/client"
+	"github.com/openpubkey/openpubkey/client/mocks"
+	"github.com/openpubkey/openpubkey/verifier"
+	"github.com/stretchr/testify/require"
+)
+
+func TestVerifier(t *testing.T) {
+	clientID := "verifier"
+	commitmentClaim := "nonce"
+	provider, err := mocks.NewMockOpenIdProvider(t, map[string]any{
+		"aud": clientID,
+	})
+	require.NoError(t, err)
+
+	opkClient, err := client.New(provider)
+	require.NoError(t, err)
+	pkt, err := opkClient.Auth(context.Background())
+	require.NoError(t, err)
+
+	// The below vanilla check is redundant since there is a final verification step as part of the PK token issuance
+	pktVerifier, err := verifier.New(provider.Verifier())
+	require.NoError(t, err)
+	err = pktVerifier.VerifyPKToken(context.Background(), pkt)
+	require.NoError(t, err)
+
+	// Check if verification fails with incorrect issuer
+	wrongIssuer := "https://evil.com/"
+	providerVerifier := verifier.NewProviderVerifier(wrongIssuer, commitmentClaim, verifier.ProviderVerifierOpts{SkipClientIDCheck: true})
+	pktVerifier, err = verifier.New(providerVerifier)
+	require.NoError(t, err)
+	err = pktVerifier.VerifyPKToken(context.Background(), pkt)
+	require.Error(t, err)
+
+	// Check if verification fails with incorrect commitment claim
+	wrongCommitmentClaim := "evil"
+	providerVerifier = verifier.NewProviderVerifier(provider.Verifier().Issuer(), wrongCommitmentClaim, verifier.ProviderVerifierOpts{SkipClientIDCheck: true})
+	pktVerifier, err = verifier.New(providerVerifier)
+	require.NoError(t, err)
+	err = pktVerifier.VerifyPKToken(context.Background(), pkt)
+	require.Error(t, err)
+
+	// When "aud" claim is a single string, check that Client ID is verified when specified correctly
+	providerVerifier = verifier.NewProviderVerifier(provider.Verifier().Issuer(), commitmentClaim, verifier.ProviderVerifierOpts{ClientID: clientID})
+	pktVerifier, err = verifier.New(providerVerifier)
+	require.NoError(t, err)
+	err = pktVerifier.VerifyPKToken(context.Background(), pkt)
+	require.NoError(t, err)
+
+	// When "aud" claim is a single string, check that an incorrect Client ID when specified, fails
+	wrongClientID := "super_evil"
+	providerVerifier = verifier.NewProviderVerifier(provider.Verifier().Issuer(), commitmentClaim, verifier.ProviderVerifierOpts{ClientID: wrongClientID})
+	pktVerifier, err = verifier.New(providerVerifier)
+	require.NoError(t, err)
+	err = pktVerifier.VerifyPKToken(context.Background(), pkt)
+	require.Error(t, err)
+
+	// If audience is a list of strings, make sure verification holds
+	provider, err = mocks.NewMockOpenIdProvider(t, map[string]any{
+		"aud": []string{clientID},
+	})
+	require.NoError(t, err)
+
+	opkClient, err = client.New(provider)
+	require.NoError(t, err)
+	pkt, err = opkClient.Auth(context.Background())
+	require.NoError(t, err)
+
+	// When "aud" claim is a list of strings, check that Client ID is verified when specified correctly
+	providerVerifier = verifier.NewProviderVerifier(provider.Verifier().Issuer(), commitmentClaim, verifier.ProviderVerifierOpts{ClientID: clientID})
+	pktVerifier, err = verifier.New(providerVerifier)
+	require.NoError(t, err)
+	err = pktVerifier.VerifyPKToken(context.Background(), pkt)
+	require.NoError(t, err)
+
+	// When "aud" claim is a list of strings, check that an incorrect Client ID when specified, fails
+	providerVerifier = verifier.NewProviderVerifier(provider.Verifier().Issuer(), commitmentClaim, verifier.ProviderVerifierOpts{ClientID: wrongClientID})
+	pktVerifier, err = verifier.New(providerVerifier)
+	require.NoError(t, err)
+	err = pktVerifier.VerifyPKToken(context.Background(), pkt)
+	require.Error(t, err)
+
+	// Specify a custom public key discoverer that returns the incorrect key and check that verification fails
+	customKeyDiscoverer := func(ctx context.Context, header jws.Headers, issuer string) (crypto.PublicKey, error) {
+		alg := jwa.RS256
+		signer, err := rsa.GenerateKey(rand.Reader, 2048)
+		if err != nil {
+			return nil, err
+		}
+
+		jwkKey, err := jwk.PublicKeyOf(signer)
+		if err != nil {
+			return nil, err
+		}
+		jwkKey.Set(jwk.AlgorithmKey, alg)
+
+		kid := header.KeyID()
+		jwkKey.Set(jwk.KeyIDKey, kid)
+
+		return jwkKey, nil
+	}
+	providerVerifier = verifier.NewProviderVerifier(provider.Verifier().Issuer(), commitmentClaim, verifier.ProviderVerifierOpts{
+		ClientID:          clientID,
+		DiscoverPublicKey: customKeyDiscoverer,
+	})
+	pktVerifier, err = verifier.New(providerVerifier)
+	require.NoError(t, err)
+	err = pktVerifier.VerifyPKToken(context.Background(), pkt)
+	require.Error(t, err)
+
+	// When the PK token does not have a GQ signature but only GQ signatures are allowed, check that verification fails
+	providerVerifier = verifier.NewProviderVerifier(provider.Verifier().Issuer(), commitmentClaim, verifier.ProviderVerifierOpts{})
+	pktVerifier, err = verifier.New(providerVerifier)
+	require.NoError(t, err)
+	err = pktVerifier.VerifyPKToken(context.Background(), pkt, verifier.GQOnly())
+	require.Error(t, err)
+
+	// When the PK token has a GQ signature and only GQ signatures are allowed, check that verification succeeds
+	opkClient, err = client.New(provider, client.WithSignGQ(true))
+	require.NoError(t, err)
+	pkt, err = opkClient.Auth(context.Background())
+	require.NoError(t, err)
+
+	providerVerifier = verifier.NewProviderVerifier(provider.Verifier().Issuer(), commitmentClaim, verifier.ProviderVerifierOpts{ClientID: clientID})
+	pktVerifier, err = verifier.New(providerVerifier)
+	require.NoError(t, err)
+	err = pktVerifier.VerifyPKToken(context.Background(), pkt, verifier.GQOnly())
+	require.NoError(t, err)
+}