Signed and encrypted UserInfo
Receive UserInfo as a JWT signed by the realm or encrypted to the client's own key, and configure the app to read it.
The UserInfo endpoint answers JSON by default. A client can ask instead for a signed JWT (the app proves the answer came from Authio) or a JWT signed and then encrypted to the client's own key (only the app reads it). The form is chosen per client and applies to every UserInfo call it makes.
Which form to use
| JSON | Signed JWT | Encrypted to the client's key | |
|---|---|---|---|
Content-Type | application/json | application/jwt | application/jwt |
| What the app gains | Nothing to configure | Proof of origin and audience (iss, aud) | Proof of origin, and only the app reads the claims |
| What the app configures | Nothing | Signature validation against the realm's JWKS | The client's private key and signature validation |
Before you start
- The client's tokens must be signed with RSA or ECDsa. A UserInfo signed with HMAC could not be verified by the app, and Authio refuses that combination.
- For the encrypted form, the client needs its own encryption key (generated or imported). Token encryption can stay off: the two are independent.
- The private key lives on the app's server. A SPA or a mobile app has nowhere to keep it: use the signed JWT at most for those clients.
Step by step in Authio
Generate the client's key (encrypted form only)
In Clients, open the client and go to Edit → Security → Token Encryption → Generate key pair. Use Download JWK before closing the dialog: the private key is shown once. If the client already holds a key for its tokens, it is the same one; do not generate another.
Check the encryption algorithms
UserInfo is encrypted with the algorithms of the Token Encryption group, even with token encryption off. Leave JWE Algorithm on RSA, Key Encryption Algorithm on
RSA-OAEPand Content Encryption Algorithm on a CBC-HMAC, such asA256CBC-HS512.Choose the response form
In Security → UserInfo response, turn on Encrypted to the client's key (Signed JWT turns on with it, since an encrypted answer is always signed first) or only Signed JWT. Save.
Configure the app and deploy
An app configured for JSON refuses a JWT, and the other way around. Update the app (see below) and deploy it together with the change in Authio.
What the endpoint answers
- Signed: a JWT with the UserInfo claims, signed with the same key and algorithm as the client's ID token, with
issthe realm's issuer,audtheclient_idand a shortexp. - Encrypted: that same JWT inside a JWE (five parts) to the client's key, with the
algandencconfigured on the client. Once opened, it is validated like the signed one. - The
subis the same as the ID token's. Check it matches before using the other claims.
curl -i https://<your-authio>/realms/<realm>/protocol/openid-connect/userinfo \
-H "Authorization: Bearer <access token>"
# HTTP/1.1 200 OK
# Content-Type: application/jwt
#
# eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZDQkMtSFM1MTIi... (five parts: encrypted)Configure your app
.NET (Authio.Client)
IAuthioUserInfoClient (Authio.Client 0.6.0 or later) reads all three forms. A signed JWT is verified against the realm's JWKS (iss the issuer, aud the client); an encrypted one is opened with Authio:ClientEncryptionKey (the private JWK or the PEM) and then verified. An answer that does not verify, or that no key opens, throws AuthioProtocolException with invalid_userinfo.
{
"Authio": {
"Authority": "https://<your-authio>",
"Realm": "<realm>",
"ClientId": "<client>",
"ClientEncryptionKey": "<the private JWK or the PEM, from a secret>"
}
}BFF and ASP.NET Core's OpenID Connect handler
GetClaimsFromUserInfoEndpoint off and call UserInfo through IAuthioUserInfoClient.Next.js
authio-provider-nextauth signs in with the ID token and never calls UserInfo, so turning UserInfo into a JWT does not change sign-in. If your app calls UserInfo itself, open the answer with decryptAuthioToken (which uses AUTH_AUTHIO_ENCRYPTION_KEY) and validate the signature with jose:
import { decryptAuthioToken } from 'authio-provider-nextauth/v5'
import { createRemoteJWKSet, jwtVerify } from 'jose'
const issuer = process.env.AUTH_AUTHIO_ISSUER!
const jwks = createRemoteJWKSet(
new URL(`${issuer}/protocol/openid-connect/certs`),
)
export async function readUserInfo(accessToken: string) {
const response = await fetch(`${issuer}/protocol/openid-connect/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!response.headers.get('content-type')?.includes('application/jwt')) {
return response.json()
}
// Opens the JWE; a JWT that is only signed comes back as it is
const signed = await decryptAuthioToken(await response.text())
const { payload } = await jwtVerify(signed, jwks, {
issuer,
audience: process.env.AUTH_AUTHIO_ID,
})
return payload
}openid-client (Node)
openid-client reads whichever form the client metadata names: userinfo_signed_response_alg for the signed one and, for the encrypted one, also userinfo_encrypted_response_alg/enc and the private key in jwks, with alg added to the JWK (it only tries a key whose alg matches the JWE's).
const key = JSON.parse(process.env.AUTHIO_ENCRYPTION_KEY!)
const client = new issuer.Client(
{
client_id: '<client>',
client_secret: '<secret>',
userinfo_signed_response_alg: 'RS256', // the client's signing algorithm
userinfo_encrypted_response_alg: 'RSA-OAEP',
userinfo_encrypted_response_enc: 'A256CBC-HS512',
},
{ keys: [{ ...key, alg: 'RSA-OAEP' }] },
)
const claims = await client.userinfo(accessToken)Any OIDC client
- Call UserInfo with the access token and look at the
Content-Type:application/jwtmeans the JWT form. - With five parts, decrypt it with the client's private key, accepting the
algandencconfigured on the client. - Validate the signed JWT against the realm's JWKS: signature,
iss(the realm's issuer),aud(yourclient_id) andexp. - Check the
submatches the ID token's before using the other claims.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| The app fails to read UserInfo after the change | The library expects JSON and got a JWT (or the other way around). Configure the same form on both sides. |
invalid_userinfo (.NET) | The signature did not verify or no key opened the JWE. Check Authio:ClientEncryptionKey and that the key is the client's current one. |
| The Encrypted to the client's key switch is locked | The client has no key of its own yet. Generate or import one under Token Encryption. |
| Authio refuses to save the signed UserInfo | The client's tokens are signed with HMAC. Switch to RSA or ECDsa. |
| GCM content algorithm unavailable | Authio only encrypts with CBC-HMAC (A128CBC-HS256, A192CBC-HS384, A256CBC-HS512). |