Token signing
Choose how a client signs its ID token and its access token, when HS256 with the client secret makes sense, and how apps, APIs and MCP servers verify each one.
Every token Authio issues is a signed JWT. A client has two signing settings: one for what only your app reads (the ID token, the signed UserInfo response and the back-channel logout token) and one for the access token your APIs read. The refresh token follows neither: Authio always signs it with its own key. For tokens nobody outside the app should read, see Encrypted tokens.
The two settings
In Clients, open the client and go to Edit → Security → Token Configuration. Each setting takes RS, PS or ES (asymmetric, verified with the realm’s public keys) or HS (HMAC, keyed with the client secret), in 256, 384 or 512 bits.
| ID token & userinfo signature | Access token signature | |
|---|---|---|
| Signs | The ID token, the signed UserInfo response and the back-channel logout token | The access token |
| Read by | Your app only | Your APIs: the client’s own API, resource servers, MCP servers |
| With RS, PS or ES | The app verifies it against the realm’s JWKS | Any API verifies it against the realm’s JWKS |
| With HS | The app verifies it with its own client secret | Only who holds the client secret validates it locally; every other API validates it by introspection |
| Left out when the client is created | The family’s default: PS256 for RSA, ES256 for ECDsa, HS256 for HMAC | The ID token’s algorithm when it is asymmetric, otherwise PS256 |
The refresh token is always signed by Authio
When to use HS256
HMAC suits a confidential client whose tokens are read only by its own app and its own API, both of which already hold the client secret: they verify tokens without fetching a JWKS or caching keys. In every other case prefer an asymmetric algorithm: anyone verifies with the public key, nothing secret is shared, and whoever can verify a token cannot also sign one.
| Situation | Choose |
|---|---|
| Only your app reads the ID token, and it holds the secret | HS for the ID token is fine |
| Only your own API reads the access token, and it holds the secret | HS for the access token is fine |
| Third-party APIs, resource servers or MCP servers read the access token | RS, PS or ES for the access token |
| A public client: SPA, mobile app, CLI | RS, PS or ES for both: a public client has no secret |
| Several services would have to share the secret | RS, PS or ES |
Whoever verifies an HS token can also sign one
What Authio accepts
| Rule | Why |
|---|---|
| A public client cannot use HS in either setting. | HS is keyed with the client secret, and a public client has none. |
| For HS, the client secret must be at least 32, 48 or 64 bytes for HS256, HS384 or HS512. | RFC 7518 §3.2 asks for a key at least as long as the hash. Rotate the secret, or choose a shorter hash. |
| When the ID token and the access token are both asymmetric, they use the same algorithm. | A client holds a single signing key, which signs both tokens and every refresh token. |
| HS for the ID token with an asymmetric access token is accepted, and so is the reverse. | The HS token uses the secret; the other token and the refresh token use the client’s key. |
A refusal names the field at fault in notifications: clientConfiguration.clientTokenConfiguration.idTokenSigningAlgorithmType for a public client asking for HS on the ID token, clientConfiguration.clientTokenConfiguration.idTokenSigningAlgorithm for a secret too short for it, and clientConfiguration.clientTokenConfiguration.accessTokenSigningAlgorithm for anything about the access token, two different asymmetric algorithms included.
In the Admin API
The settings live in clientConfiguration.clientTokenConfiguration. algorithmJwsType is now idTokenSigningAlgorithmType and digitalSubscriptionsAlgorithm is now idTokenSigningAlgorithm, with no alias; accessTokenSigningAlgorithm is new and takes the same values. On create it is optional (left out, the default above applies); on update, leaving it out keeps the current algorithm.
{
"clientConfiguration": {
"clientTokenConfiguration": {
"idTokenSigningAlgorithmType": "HMAC",
"idTokenSigningAlgorithm": "HmacSha256",
"accessTokenSigningAlgorithm": "RsaSsaPssSha256"
}
}
}| Algorithm | Family (idTokenSigningAlgorithmType) | Value |
|---|---|---|
| RS256, RS384, RS512 | RSA | RsaSha256, RsaSha384, RsaSha512 |
| PS256, PS384, PS512 | RSA | RsaSsaPssSha256, RsaSsaPssSha384, RsaSsaPssSha512 |
| ES256, ES384, ES512 | ECDsa, with jsonWebKeyECTypes | EcdsaSha256, EcdsaSha384, EcdsaSha512 |
| HS256, HS384, HS512 | HMAC | HmacSha256, HmacSha384, HmacSha512 |
APIs, resource servers and MCP servers
An access token signed with HS has no key in the realm’s JWKS. The client’s own API holds the client secret and validates it locally; any other API (a third party’s, a resource server of the realm, an MCP server) does not have the secret and must validate the token by introspection, as the resource server’s introspection client.
`accessTokenRequiresIntrospection`
accessTokenRequiresIntrospection: true when its access token is signed with HS and some resource server of the realm accepts the client, an MCP server included. The console shows it as a warning and saves the client anyway. A change to the resource servers can take up to 5 minutes to show in it.Either sign the client’s access token with RS, PS or ES, or configure those servers to validate by introspection alone:
import { authioBearer } from '@hyper.authio/express'
app.use(
'/api',
authioBearer({
issuer: process.env.AUTHIO_ISSUER!,
audiences: 'https://orders.example.com',
introspection: 'introspectionOnly',
client: { clientId: 'orders-api', clientSecret: process.env.ORDERS_API_SECRET! },
}),
)The client in each of these is the server’s own confidential client, chosen as the resource server’s Introspection client: Authio reports a token addressed to a resource server as active only to that client.
Configure your app and your API
The Authio libraries mirror the client: each setting is an option, and an option left out is off. Without a signing option a library accepts only the asymmetric algorithms the realm publishes, and refuses an HS token with invalid_algorithm, naming the option or introspection. With an HS algorithm named, it verifies with the client secret, which must meet the same length as above.
Node.js
In @hyper.authio/client and @hyper.authio/bff 0.8.0, idTokenSignedResponseAlg covers the ID token and the logout token, and userinfoSignedResponseAlg the signed UserInfo, which follows idTokenSignedResponseAlg when left out. In @hyper.authio/server and @hyper.authio/express, accessTokenSignedResponseAlg (one algorithm or several) verifies HS with the clientSecret of client: { clientId, clientSecret }.
import { createAuthioClient } from '@hyper.authio/client'
export const authio = createAuthioClient({
issuer: process.env.AUTHIO_ISSUER!,
clientId: 'storefront',
clientSecret: process.env.AUTHIO_CLIENT_SECRET!,
checks: ['pkce', 'state', 'nonce'],
codeChallengeMethod: 'S256',
idTokenSignedResponseAlg: 'HS256',
redirectUri: 'https://shop.example.com/callback',
})Without resource, the access token’s aud is the audience configured on the client (Authio unless you changed it). Any other API validates by introspection, as shown above.
.NET
Authio.Client, Authio.AspNetCore and Authio.AspNetCore.Bff 0.8.0 read IdTokenSignedResponseAlg, UserinfoSignedResponseAlg (left out, the ID token’s) and AccessTokenSignedResponseAlg (one value or a list) from the Authio section. HS is verified with ClientSecret, as UTF-8 bytes.
{
"Authio": {
"Authority": "https://authio.api.hyperio.tech",
"Realm": "acme",
"ClientId": "acme-web",
"ClientSecret": "YOUR_CLIENT_SECRET",
"IdTokenSignedResponseAlg": "HS256",
"AccessTokenSignedResponseAlg": "HS256"
}
}Next.js (NextAuth v4 and Auth.js v5)
@hyper.authio/provider-nextauth 4.0.0 takes NextAuth’s own client metadata: client.id_token_signed_response_alg for the ID token and client.userinfo_signed_response_alg for the signed UserInfo. HS is verified with clientSecret (AUTH_AUTHIO_SECRET on v5). The provider has no access token setting: it reads the access token only for the claims of session.authio, and your API verifies it.
import NextAuth from 'next-auth'
import { createAuthio } from '@hyper.authio/provider-nextauth/v5'
export const authio = createAuthio({
checks: ['pkce', 'state', 'nonce'],
client: { id_token_signed_response_alg: 'HS256' },
authorization: { params: { scope: 'openid profile email roles offline_access' } },
})
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [authio.provider],
callbacks: authio.callbacks,
events: authio.events,
})PKCE with `plain` only does not work with NextAuth
S256 challenges, and codeChallengeMethod: 'plain' is refused when the integration is created. A client with PKCE on that allows only plain refuses every sign-in from them: allow S256 on the client.Troubleshooting
| Symptom | Cause and fix |
|---|---|
invalid_algorithm in the app or the API | The client signs with HS and the library was not told. Set idTokenSignedResponseAlg, accessTokenSignedResponseAlg or client.id_token_signed_response_alg, or validate by introspection. |
invalid_signature on HS tokens | The library holds a different secret from the client’s current one, after a rotation for instance. Update it. |
| A resource server or MCP server refuses every token | The client’s access token is signed with HS. Switch it to RS, PS or ES, or validate by introspection (introspectionOnly). |
| The save refuses HS: a public client | Give the client a secret (client authentication on) or choose RS, PS or ES. |
| The save refuses HS: the secret is too short | Rotate the secret, or choose HS256, which needs 32 bytes. |
| The save refuses two asymmetric algorithms | Choose the same algorithm for both, or sign the ID token with HS. |
| The library fails at start-up asking for a longer secret | An HS algorithm is named and the client secret it was given is shorter than 32, 48 or 64 bytes. |