# Receiving encrypted PHP SDK responses

Included in **token-php 0.1.3** and **web-php 0.1.4**. Earlier immutable SDK
archives do not include this feature. Native crypto is optional for installing
and using the SDK; enable encryption only with the runtime below. The separate
native components have their own installation and distribution lifecycle.

On 2026-09-16, an isolated Apache/PHP receiver using the web candidate passed
live encrypted ID-token login, `form_post.jwt` JARM, UserInfo, strict refresh
and cancellation against the production provider. The campaign used inline
RSA JWKS, RS256 signing and RSA-OAEP-256/A256GCM; it does not establish every
transport, remote JWKS, rotation, device or ACK combination.

The broader September 20 acceptance covered the remaining encrypted JARM
transports, HTTPS recipient JWKS, retained-key rotation and negative paths.
Those provider/browser results do not substitute for checking the exact SDK
release artifacts or broaden their runtime requirements.

The PHP SDK supports nested signed JWTs encrypted with **RSA-OAEP-256/A256GCM**
for ID tokens, web JARM authorization responses, and signed UserInfo. Decryption
is followed by signature and artifact-specific claims verification. Signing
algorithms remain separately configured. Required encryption rejects readable
JWS/JSON, unsupported algorithms, unknown recipients, and authentication failures.
There is no plaintext retry or cryptographic fallback.

## Runtime and installation

Ordinary supported signing flows keep PHP **8.1+** and existing dependency rules.
RS256/RS512 use PHP OpenSSL without M7 Crypto. Existing native-only signing,
including token-php HMAC verification, still requires its native dependency.
Encryption alone requires PHP **8.4+**, **M7 PHP Crypto 0.3+**, its matching
**M7 C Crypto 0.3+** backend, and PHP OpenSSL with `aes-256-gcm`.
`ext-m7crypto` is suggested, never a mandatory Composer dependency.

The [AlmaLinux C/PHP walkthrough](https://m7.org/docs/sdk/m7-php-crypto/integration/almalinux.md)
provides a complete build, correctness/memory-test, installation and service
activation sequence. Once loaded in the receiving PHP worker, the extension is
detected by the SDK's runtime checks; recipients and encryption policy still
require explicit configuration. The current receiver has no PHP 8.5 OpenSSL
replacement backend; the guide's custom-port note describes possible future or
local adaptations rather than a supported automatic fallback.

Follow the maintained public component guides in dependency order:

1. [Build and install M7 C Crypto](https://m7.org/docs/sdk/m7-c-crypto/integration/installation.md),
   checking its [release status and integrity](https://m7.org/docs/sdk/m7-c-crypto/releases.md).
2. [Build and load M7 PHP Crypto](https://m7.org/docs/sdk/m7-php-crypto/integration/installation.md)
   against that exact C library, checking its
   [release status and integrity](https://m7.org/docs/sdk/m7-php-crypto/releases.md).

The initial validated extension target is **PHP 8.4 NTS**; other PHP versions
and ZTS builds need separate validation. Broader/lower PHP support is later
work. The current native 0.3.0 bundles are verified development candidates,
with final release and public download verification pending. Their status is
independent of the published Identity SDK ZIPs and base PHP 8.1+ installation.

The [C encryption API](https://m7.org/docs/sdk/m7-c-crypto/integration/README.md#rsa-oaep-256-encryption)
and [PHP encryption API](https://m7.org/docs/sdk/m7-php-crypto/integration/README.md#encrypt-a-small-content-key)
describe primitive operations, ownership and failure behavior.
M7 Crypto supplies RSA-OAEP-256; the SDK authenticates AES-GCM through PHP
OpenSSL. No additional JOSE dependency is required.

Check the actual interpreter used by your receiving application:

```sh
php --ini
php --ri m7crypto
php -r 'echo PHP_VERSION, "\n", OPENSSL_VERSION_TEXT, "\n"; var_export(in_array("aes-256-gcm", openssl_get_cipher_methods(), true));'
```

For HTTP use, run an access-restricted temporary health probe in the application's
PHP runtime. Call `Recipient::prepare($policy, 'id_token', $recipient)` there,
then perform a test login through that same runtime. Report only runtime version
and success/failure. Remove the probe afterward. CLI checks cannot certify Apache
or FPM. An incompatible/missing C library may prevent the extension loading at
PHP startup; SDK exceptions cannot intercept a PHP loader failure.

## Public policy and local keys

Use exactly the existing registration field names:

| Artifact | Algorithm field | Content-encryption field |
| --- | --- | --- |
| ID token | `id_token_encrypted_response_alg` | `id_token_encrypted_response_enc` |
| JARM | `authorization_encrypted_response_alg` | `authorization_encrypted_response_enc` |
| Signed UserInfo | `userinfo_encrypted_response_alg` | `userinfo_encrypted_response_enc` |

Omit both fields for an artifact to leave encryption off. Set both to
`RSA-OAEP-256` and `A256GCM` to require it. Partial, null, misspelled, and unsupported
policy values fail closed. Configuration must come from the application, never
from token headers, query parameters, discovery or a response body.

```php
use M7\Identity\ResponseEncryption\Recipient;

$recipient = new Recipient([
    ['kid' => '2026-09-primary',
     'private_pem' => file_get_contents('/srv/private/recipient-2026-09.pem')],
    ['kid' => '2026-08-retained',
     'private_pem' => file_get_contents('/srv/private/recipient-2026-08.pem')],
]);
$policy = [
    'id_token_encrypted_response_alg' => 'RSA-OAEP-256',
    'id_token_encrypted_response_enc' => 'A256GCM',
];
Recipient::prepare($policy, 'id_token', $recipient);
```

Alternatively pass a **local callable with no arguments** returning that same
list of `{kid, private_pem}` entries. It can read from your application's secret
store. The incoming token never supplies a file path, URL or provider callback.
The complete list must contain 1–32 unique, nonempty IDs and unencrypted ordinary
RSA private PEMs with 2048–8192-bit keys. Public-only, weak, non-RSA, restricted
RSA-PSS and unusable keys cannot decrypt. The entire list is validated, including
retained keys; errors never reveal the key ID, PEM or backend exception.

Keep private keys outside the public document root with access limited to the
application runtime. The recipient object redacts debug output and rejects
serialization. Do not log configuration, tokens, exception arguments or claims;
do not export your source key array. The SDK never sends private recipient keys
in registration, requests, cookies, transactions or browser configuration.
Signature-verification certificates/JWKS remain separate from recipient keys.

Register **only the public RSA JWK/JWKS** through the existing SSO registration
facility (`jwks` or `jwks_uri`) and set the matching policy fields there. This is
not a new registration API. UserInfo encryption requires registered RS256/RS512
signing. JARM requires an explicitly registered JWT response mode.

SSO chooses the bytewise lexicographically smallest eligible public `kid`.
Choose IDs deliberately when rotating; retain old private keys until all responses
and outstanding authorization/cancellation transactions have expired. Changing
local keys must not remove a recipient still needed by an in-flight response.

## token-php API

Use normal Composer autoloading, or the package's `autoload.php` for a ZIP install.
The ordinary `TokenParser` still only accepts three-part JWS and establishes no
trust by itself. Encrypted responses enter the response verifier first.

```php
use M7\Identity\Token\IdToken\IdTokenVerifier;

$idOptions = [
    'issuer' => 'https://sso.user.m7.org',
    'client_id' => '<YOUR_CLIENT_ID>',
    'nonce' => $savedAuthorizationNonce,
    'response_encryption' => $policy,
    'recipient' => $recipient,
    'validation' => [
        'allowed_algs' => ['RS512'],
        'trusted_x5u_hosts' => ['sso.user.m7.org', 'id.m7.org'],
    ],
];
$verified = (new IdTokenVerifier())->verify($tokenResponse['id_token'], $idOptions);
$claims = $verified->claims();
```

`IdTokenOptions::fromArray()` also accepts `expected_sub`. `validation` uses the
existing token validation/key-resolution API, including trusted `cert_pem` and
native signing profiles. Issuer and audience are always bound to `issuer` and
`client_id`, regardless of generic validation overrides. ID tokens require
`sub`, integer `iat`/`exp`, valid issuer/audience/times, appropriate `azp`, and a
matching saved nonce/subject when supplied. Supply the nonce you saved when
starting authorization or device authorization; never take the expected nonce
from the returned ID token. Refresh may omit a nonce; supply `expected_sub` from
the established identity when available.

Pass the same array under **`id_token`** to `RefreshTokenClient::exchange()` or
`DeviceCodeClient::poll()` (and the existing SDK facade methods forwarding those
options). Verification runs before the successful grant package is released.
Its client ID must match the grant's configured authentication client. Reports
retain the original encrypted `id_token`; use `IdTokenVerifier` to obtain trusted
claims. Access/refresh/session values and ACK state are unchanged. `id_token`
options require a returned ID token, so use them only for OIDC grant contexts.
Token-php has no authorization-code exchange client; use the standalone verifier
for an application-owned code exchange. Without `id_token` options, historical
signed ID-token passthrough remains; an unexpected JWE is rejected.

For UserInfo, pass `response_encryption` with the **userinfo** pair and `recipient`
in `UserInfoOptions`/`UserInfoClient::fetch()` options. Preserve `issuer`,
`client_id` and `expected_sub`. Successful `application/jwt` responses are
unwrapped and verified through the existing issuer JWKS provider. Required
UserInfo encryption rejects successful JSON responses. `JwtUserInfoVerifier`
also accepts the same options for application-owned HTTP transport.

## web-php setup

Register a confidential server-side client for the complete Web SDK session and
profile flow, for example `client_secret_basic`. Its introspection calls reject
public-client `none` even though encryption itself permits public clients. Keep
the authentication secret separate from the private decryption key.

Install the package using `bin/m7-identity-web-install` as documented by the web
package. The installed tree contains its own copy of the shared adapter and does
not need token-php, provider repositories, or test files at runtime.

Set `M7_RESPONSE_ENCRYPTION_CONFIG_FILE` to an absolute PHP config file **outside
the public document root**, readable by the server. It returns only registration
policy fields and an optional `recipient` object. The SDK loads `Recipient` before
requiring this file. Example file:

```php
<?php
use M7\Identity\ResponseEncryption\Recipient;
return [
    'id_token_encrypted_response_alg' => 'RSA-OAEP-256',
    'id_token_encrypted_response_enc' => 'A256GCM',
    'authorization_encrypted_response_alg' => 'RSA-OAEP-256',
    'authorization_encrypted_response_enc' => 'A256GCM',
    'userinfo_encrypted_response_alg' => 'RSA-OAEP-256',
    'userinfo_encrypted_response_enc' => 'A256GCM',
    'recipient' => new Recipient(static function (): array {
        return [['kid' => '2026-09-primary',
            'private_pem' => file_get_contents('/srv/private/recipient-2026-09.pem')]];
    }),
];
```

Set `M7_RESPONSE_MODE=form_post.jwt` explicitly and set
`M7_AUTHORIZATION_SIGNED_RESPONSE_ALG=RS256` for the registered JARM policy.
`M7_ID_TOKEN_SIGNED_RESPONSE_ALG` is an optional ID-token/logout pin: omit it to
verify the incoming supported algorithm, or set it to require that exact
algorithm. There is no default ID-token/logout pin.
The web ID-token and logout verifiers support RS256/RS512 through PHP OpenSSL
and Ed25519 through `ext-m7crypto` with issuer-discovered OKP keys. JARM and
UserInfo remain RS256/RS512; token-php retains its broader signing verification API. The login and signup routes check dependencies, then
save public encryption policy, issuer/client/signing context and nonce in the
server session. It never saves the recipient/private PEM. Callback JARM uses
the saved policy and only releases code/state/errors after complete verification.
The code exchange consumes the saved ID-token context once. Refresh uses current
trusted configuration. Required encrypted ID tokens are verified before session
cookies or pending ACK packages are written. They are not added to cookies or
returned to browser code. Signed-only web ID-token behavior remains unchanged.

`m7_bff_verify_id_token()`, `m7_callback_verify_jarm()` and
`m7_bff_verify_userinfo_jwt()` accept local `response_encryption` and `recipient`
options for embedding/testing. The installed routes derive these from server
configuration and transaction state, never browser input.

## Limits and failures

The adapter caps outer JWE at 32 KiB for JARM and 256 KiB for ID token/UserInfo,
protected-header encoding at 2048 bytes, and recovered signed JWT at 128 KiB
(16 KiB for web JARM). Base64url must be canonical; protected metadata cannot
contain duplicate names, compression, critical extensions, key URLs or embedded
keys. CEKs must be 32 bytes, IVs 12 bytes, authentication tags 16 bytes.

The SDK intentionally retains stricter existing transport limits:

- Web callback original encoded query/form/fragment: **16 KiB total**, including
  the `response=` field. Same-origin relay wrapper: 64 KiB. Thus some provider
  JWEs below its 32 KiB ceiling will be rejected. Prefer `form_post.jwt`; do not
  enlarge only one browser/server limit independently.
- Web UserInfo body: **128 KiB**. Token-php UserInfo defaults to **64 KiB** and
  has configurable `max_response_bytes`. Set it to 262144 to receive the provider
  ceiling; recovered JWS still cannot exceed 128 KiB.
- Token-php grant JSON defaults to **128 KiB**, configurable through
  `max_response_bytes`. Set 524288 to allow a 256 KiB ID token plus other fields.
  Web encrypted token exchanges cap the complete HTTP response at **512 KiB**.
- Access/refresh cookie and pending-envelope limits do not change. ID tokens
  are verified on the server without being added to cookie storage.

Preflight errors use `response_encryption_unavailable`,
`response_encryption_recipient_missing`, `response_encryption_policy_invalid`,
or `response_encryption_keys_invalid`. Invalid encrypted messages use
`encrypted_response_invalid`. ID verification exposes `id_token_validation_failed`;
web JARM/UserInfo retain their existing fixed failure results. Backends and local
key providers are not chained into these errors. No decrypted claims are released
on failure. Do not disable encryption to recover from an error: repair the key,
runtime or registration mismatch and begin a new authorization if needed.

## Verify your integration

Use a dedicated application and keep only status/validation outcomes as evidence.
Confirm the PHP runtime used by actual HTTP requests, not only the CLI.

1. Match issuer, client ID, exact redirect, registered signing algorithms and
   public recipient keys with protected local configuration.
2. Require ID-token encryption on both sides; verify a fresh login and refresh.
   Validate the saved nonce, issuer and client audience before accepting identity.
3. Require JARM, select `form_post.jwt`, and test success and cancellation. Check
   wrong-state, replay and tampered-envelope rejection before code exchange.
4. Require signed UserInfo encryption, verify the established subject, and test
   that JSON or readable JWS cannot satisfy required encryption.
5. Test all three together, then cover the transports and device/ACK lifecycle
   your application uses. Preserve complete pending packages where ACK applies.
6. Rotate with old and new private recipients retained. Test unknown recipients,
   altered ciphertext/tags, remote-key outages and complete proxy/body limits.
7. In an isolated runtime without M7 Crypto, ordinary supported signing remains
   usable; configured encryption must fail with `response_encryption_unavailable`.
   Restore the runtime instead of disabling a required policy.

Local interoperability and negative tests cover additional algorithm, key,
runtime and protocol failures. The scoped live campaign above proves the stated
path only. Candidate success does not add encryption to an older immutable ZIP
or publish a new package version. The dedicated CLI executable does not acquire
new recipient options merely because the Token/PHP library supports them.
