Use case — Protect an API (resource server)
The op package issues tokens. The op/rs package checks them on the other side: it turns an incoming request into a verified token, or into an error carrying the HTTP status and the WWW-Authenticate challenge the specifications prescribe. Use it when you write the API yourself and want the OP's tokens to guard it, in the same process or in another one.
If the API only needs to know whether a token is valid and you already run a gateway that verifies JWTs, op/rs adds little. It earns its place when you want sender-constraint checks (DPoP, mTLS), scope and step-up requirements, and correct challenges without assembling them from the RFCs.
Specs referenced on this page
Source:
examples/36-resource-serverruns an OP and an/api/ordersroute guarded byop/rs, calls it as a machine client, and prints the success and three refusals.
Build the verifier
import "github.com/libraz/go-oidc-provider/op/rs"
verifier, err := rs.New(rs.Config{
Issuer: "https://op.example.com",
Audience: "https://api.example.com",
})Issuer and Audience are required. rs.New does no network I/O; the OP's discovery document and JWK Set are fetched on first use and cached. Build one *rs.Verifier at startup and share it, since it is safe for concurrent use.
| Field | Meaning |
|---|---|
Issuer | The iss of every accepted token must equal it byte for byte. Discovery at Issuer + "/.well-known/openid-configuration" must name the same issuer. |
Audience | This API's identifier. A token is accepted only when its aud (a string or an array) contains it. It is also the realm of the Bearer challenge. |
JWKSURI | The JWK Set URL. Empty takes jwks_uri from discovery. |
Introspection | Enables opaque tokens; see below. |
DPoP | rs.DPoPAllowed (zero value) or rs.DPoPRequired. |
DPoPReplay | A store.ConsumedJTIStore for used DPoP proofs. Nil keeps them in process. |
MTLSProxy | The *op.MTLSProxy naming a trusted proxy and its certificate header, the value op.WithMTLSProxy records and (*op.Provider).MTLSProxy() reads back. |
HTTPClient, Logger, Clock | Transport and timeout for outbound fetches, a logger for their failures, and a time source. |
Every outbound fetch (discovery, JWK Set, introspection) goes through the library's SSRF-hardened client: https is required, redirects are never followed, responses are size-capped, and private addresses are refused. AllowPrivateNetwork admits private addresses, and plain http to a loopback IP literal, for local development only. Example 36 sets it because its issuer is http://127.0.0.1:8080.
Guard routes
Middleware wraps a handler with the verification plus the requirements the route needs:
requireOrdersRead := verifier.Middleware(rs.RequireScope("orders:read"))
mux.Handle("/api/orders", requireOrdersRead(http.HandlerFunc(listOrders)))A request that passes reaches the handler with the token in its context. A request that fails gets the error's status, its challenge and an empty body, and the handler does not run.
Read the token inside the handler with rs.FromContext:
func listOrders(w http.ResponseWriter, r *http.Request) {
tok, ok := rs.FromContext(r.Context())
if !ok {
http.Error(w, "no verified token", http.StatusInternalServerError)
return
}
// tok.Subject, tok.ClientID, tok.Scopes, ...
}rs.Token carries Subject, ClientID, Scopes, ACR, AMR, AuthTime, ExpiresAt, the sender constraint in Binding (JKT or CertThumbprint, both empty for a bearer token), and every claim of the payload in Claims. For a client_credentials token the subject is the client's own identifier.
Code that does not sit behind net/http calls verifier.Verify(r) directly. It returns the *rs.Token, or an error that is always an *rs.Error with Status, Code, Description and WWWAuthenticate().
The three requirements are a closed set:
| Requirement | Fails with |
|---|---|
rs.RequireScope("orders:read", ...) | 403 insufficient_scope, the required scopes in the scope attribute |
rs.RequireACR(levels, acr) | 401 insufficient_user_authentication, acr_values in the challenge |
rs.RequireMaxAge(d) | 401 insufficient_user_authentication, max_age in whole seconds |
RequireACR takes the same op.ACRLevels value the OP was configured with, so both sides rank acr values identically, and it panics at wiring time if acr is not declared in levels. RequireMaxAge needs a positive duration, and a token without auth_time does not meet it. A nil requirement panics in Middleware instead of leaving the route unguarded. The OP's half of this step-up is described in MFA / step-up.
What is checked
A JWT access token is verified locally against the issuer's JWK Set (RFC 9068 §4):
- the
typheader isat+jwtorapplication/at+jwt - the signature, with an
algof RS256, PS256, ES256 or EdDSA, and equal to the JWK'salgwhen the key declares one;noneand the HMAC family are never accepted - the required claims,
iss,aud,expandiat, with no leeway on either time
A kid the cached set does not hold triggers one throttled refetch, which is how a key rotation propagates to the API. A JWT that fails verification is not retried through introspection.
Opaque tokens
Tokens that are not JWTs go to RFC 7662 introspection when Config.Introspection is set, and are rejected as invalid_token otherwise. The API authenticates to the OP as a client:
verifier, err := rs.New(rs.Config{
Issuer: "https://op.example.com",
Audience: "https://api.example.com",
Introspection: &rs.IntrospectionConfig{
ClientID: "orders-api",
ClientSecret: secret,
},
})Endpoint is optional and defaults to introspection_endpoint from discovery. Choose exactly one way for the API to authenticate; rs.New fails for none or for more than one:
| Set | Method | Notes |
|---|---|---|
ClientSecret | client_secret_basic | |
PrivateKey (a crypto.Signer) and KeyID | private_key_jwt | the assertion's aud is the issuer; ES256 for a P-256 key, PS256 for RSA, EdDSA for Ed25519 |
TLSCertificate | tls_client_auth or self_signed_tls_client_auth | with an empty Endpoint, the mtls_endpoint_aliases introspection endpoint from discovery is used when the OP publishes one |
The verifier asks for an RFC 9701 JWT introspection response and verifies it (typ of token-introspection+jwt, the issuer as iss, the client's ID in aud, signature against the OP's JWK Set). It still accepts a plain JSON response from an OP that sends one. On the OP side, three things have to line up:
op.WithFeature(feature.Introspect),
op.WithAccessTokenFormat(store.AccessTokenFormatOpaque), // when the tokens are opaque
op.WithProtectedResources(op.ProtectedResource{
Resource: "https://api.example.com",
IntrospectionClients: []string{"orders-api"},
}),orders-api must also be a registered confidential client. The OP answers an introspection request with the token's contents only when the authenticating client is listed in the IntrospectionClients of a resource the token is addressed to. Otherwise every token reads as inactive, and the API rejects every call. The delegation is scoped to that resource: tokens for another resource stay invisible to the client, and refresh tokens are never covered. An opaque token store has to be present on the OP side (Store.OpaqueAccessTokens is required when the format is opaque).
The default access token format is JWT. An inactive answer is invalid_token. A transport or protocol failure is the server's own fault and surfaces as 503.
rs does not cache introspection results, so each call to the API makes one request to the OP. Put a cache in front if that cost matters, or issue JWTs.
Bind tokens to a sender
A token whose cnf carries jkt is bound to a DPoP key (RFC 9449). It must arrive under the DPoP scheme with a valid proof for the same key: the proof's htu and htm must match the request, its ath must match the token, and its jti is consumed once. A token whose cnf carries x5t#S256 is certificate-bound (RFC 8705 §3), and the connection must present the bound certificate. The cnf of an introspection response is enforced the same way as the cnf of a JWT.
DPoPAllowedaccepts bearer tokens, and accepts a DPoP-bound token only under the DPoP scheme. The challenge offers both schemes.DPoPRequiredaccepts only DPoP-bound tokens under the DPoP scheme. The challenge offersDPoPalone.- A bound token presented as a plain bearer is
invalid_token. A bad proof isinvalid_dpop_proof, and a replayed proof is rejected too.
The verifier checks the proof's htu against the URL the request was addressed to, built from r.URL, r.Host and r.TLS. Behind a reverse proxy that rewrites the host or terminates TLS, restore those fields before the request reaches the verifier.
By default DPoPReplay is an in-process store, so a replay is detected only on the instance that saw the first use. With more than one instance, supply a shared store such as the one your OP uses; rs.New logs one warning for the in-process default when Logger is set. The verifier does not issue DPoP nonces. For certificate binding behind a proxy that terminates client TLS, set MTLSProxy.
With a FAPI 2.0 OP
Against an OP that runs profile.FAPI2Baseline or profile.FAPI2MessageSigning:
- Prefer JWT access tokens.
rsverifies them locally, with no call to the OP and no client credential to manage. - For opaque tokens, authenticate with a key or a certificate. The FAPI profiles refuse
client_secret_basic, so setIntrospectionConfig.PrivateKeywithKeyID(the key's public half registered as the API client's JWKS), orTLSCertificatefor an mTLS client. An introspection client with a shared secret is rejected by the OP. - Expect a signed answer from Message Signing.
profile.FAPI2MessageSigningmakes/introspectrespond with an RFC 9701 JWT whatever the request asks for, andrsverifies it against the OP's JWK Set without extra configuration. - Check the sender constraint. A DPoP-bound or certificate-bound token is enforced as described in Bind tokens to a sender.
The OP side of these profiles is described in FAPI 2.0 Baseline and FAPI 2.0 Message Signing.
Challenges
On failure the middleware writes the status and a WWW-Authenticate header, and no body:
| Situation | Status | error |
|---|---|---|
No Authorization header | 401 | none: a bare challenge with the realm |
| More than one header, unknown scheme, malformed token | 400 | invalid_request |
| Wrong issuer or audience, expired, bad signature, inactive, binding mismatch | 401 | invalid_token |
| Bad or replayed DPoP proof | 401 | invalid_dpop_proof |
| Missing scope | 403 | insufficient_scope |
acr or max_age not met | 401 | insufficient_user_authentication |
| Discovery, JWK Set, introspection or the replay store unreachable | 503 | none; no challenge is written |
Under DPoPAllowed the header holds two challenges, Bearer realm="https://api.example.com", ... and DPoP algs="...". The error attributes ride only on the challenge of the scheme the client used, and a request without credentials gets neither. error_description is a fixed string that never echoes request data. The scope, acr_values and max_age attributes carry what the route required, which is what a client needs to start a step-up (RFC 9470 §3).
Choose the audience
The verifier accepts a token only when Config.Audience is among its aud values, so the OP has to address tokens to the API. A client asks for that with the RFC 8707 resource parameter on the token request:
curl -u orders-client:... \
-d 'grant_type=client_credentials&scope=orders:read' \
--data-urlencode 'resource=https://api.example.com' \
https://op.example.com/oidc/tokenThree settings make it work:
op.WithProtectedResources(op.ProtectedResource{Resource: "https://api.example.com", ...})declares the API. The OP publishes it as protected-resource metadata and issues tokens for it.- The client's
Resourceslist names the resources it may request. A client with an empty list has itsresourcerequests rejected. Config.Audienceis that same identifier.
Without resource, the OP addresses the token to itself, with the issuer as aud, and the API refuses it. Example 36 shows all three refusals: no token, a token without the scope, and a token for another audience. See Protected resource metadata for what the OP publishes about the API.
Calling the API and /userinfo from the same SPA
A SPA that wants to call your API and read the user's profile hits an audience rule. /userinfo accepts an access token only when the token's aud contains the OP's issuer. A token requested with resource=https://api.example.com is addressed to the API alone, so the OP answers /userinfo with 401 invalid_token, and the same token is exactly what the API's verifier wants. A token requested without resource has the issuer as aud and works at /userinfo, but the API refuses it.
Nothing converts one into the other. A refresh token is bound to the resource of the grant that created it, so a refresh_token request cannot change the audience of the access token it returns. The OP does not relax the /userinfo check either: accepting tokens addressed to a resource server would let any API holding such a token read the user's profile, and the API's own audience check is the only thing that stops it.
The recommended shape is to read the profile from the ID Token, which the token endpoint returns alongside the access token. Name the scopes whose claims should travel in it:
op.New(
/* required options */
op.WithProtectedResources(op.ProtectedResource{Resource: "https://api.example.com"}),
op.WithIDTokenScopeClaims("profile", "email"),
)With that option, the ID Token of the authorization_code, refresh_token, device_code and CIBA grants carries the claims that profile and email release at /userinfo, whenever the response's scope includes them. The SPA gets the profile and the API-bound access token from one token response. The option departs from OIDC Core 1.0 §5.4 on purpose, and it enlarges the ID Token; see Scopes. Without it, the other route is a second authorization request without resource, whose access token is for /userinfo only.
Where the API sits
The API can share a process and a router with the OP, or live apart. When they share a router, mount the OP route by route so the application's own 404s and API routes stay yours; example 36 does exactly that.
Next
- MFA / step-up: the OP half of
acrandmax_agerequirements. - DPoP nonce flow: server-supplied nonces, which
op/rsdoes not issue. - Start here: pick the next page by goal.