ziming / laravel-myinfo-sg
Laravel Package for Singapore MyInfo
Fund package maintenance!
Requires
- php: ^8.4
- ext-gmp: *
- ext-json: *
- ext-openssl: *
- archtechx/enums: ^1.0
- guzzlehttp/guzzle: ^7.15.3||^8.0
- illuminate/contracts: ^12.0||^13.0
- saloonphp/cache-plugin: ^3.1
- saloonphp/saloon: ^4.0
- spatie/laravel-package-tools: ^1.16.6
- spomky-labs/aes-key-wrap: ^7.0||^8.0
- web-token/jwt-framework: ^4.0
Requires (Dev)
- larastan/larastan: ^3.0
- nunomaduro/collision: ^8.0
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^5.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- spatie/laravel-ray: ^1.37.1
- symfony/thanks: ^1.3
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-master
- 4.0
- 3.14159265
- 3.14.15926
- 3.14.1592
- 3.14.159
- 3.14.15
- 3.14.1
- 3.14
- 3.13
- 3.12
- 3.11.5
- 3.11.4
- 3.11.3
- 3.11.2
- 3.11.1
- 3.11
- 3.9
- 3.8
- 3.7
- 3.6.1
- 3.6
- 3.5
- 3.4
- 3.3.1
- 3.3
- 3.2.2
- 3.2.1
- 3.2
- 3.1.0
- 3.0.2
- 3.0.1
- 3.0
- 2.3.3
- 2.3.2
- 2.3.1
- 2.3
- 2.2.2
- 2.2.1
- 2.2.0
- 2.1.1
- 2.1
- 2.0
- 1.15
- 1.14
- 1.13
- 1.12
- 1.10
- 1.9
- 1.8
- 1.7
- 1.6
- 1.5
- 1.4
- 1.3
- 1.2
- 1.1
- 1.0
- 0.10
- 0.9
- 0.8
- 0.7
- 0.6
- 0.5
- 0.4
- 0.3
- 0.2
- 0.1
- dev-no-idea
- dev-huge-waste-of-time
- dev-hdb-type-enum
This package is auto-updated.
Last update: 2026-09-26 05:24:15 UTC
README
A Laravel package for integrating with Singapore's MyInfo through Singpass FAPI 2.0. It provides a session-backed authorization flow, verified identity and personal-data responses, and JWKS management commands.
Official Singpass documentation
Contents
- Requirements
- Supported integration
- Installation
- Configuration
- Redirect the user to Singpass
- Handle the callback
- Public JWKS endpoint
- Errors and transport recovery
- Generate JWKS
- Rotate JWKS
- Development
Requirements
- PHP 8.4 or later within the PHP 8.x series, with the GMP, JSON, and OpenSSL extensions.
- Laravel 12 or 13.
- A Singpass application with a client ID, registered redirect URI, approved scopes, and registered public JWKS.
- A persistent Laravel session shared by the authorization and callback routes.
Supported integration
This guide covers the FAPI 2.0 implementation in the MyinfoV5 namespace, configured through
laravel-myinfo-sg-v5.
Version naming: In this checkout,
MyinfoV5refers to the FAPI 2.0 integration. Earlier package releases usedMyinfoV6for this flow andMyinfoV5for the previous implementation. When using an older release, consult the README shipped with that release; namespaces and configuration may differ.
The connector handles these parts for you:
- OpenID discovery
- PAR (Pushed Authorization Request)
- PKCE
- DPoP
- client assertion signing
- ID token JWE decryption
- ID token JWS signature verification
- ID token issuer, audience, expiry, issued-at, and nonce verification
- UserInfo JWE decryption and JWS signature verification
- UserInfo issuer, audience, issued-at, subject, and
person_infoverification - ID-token-to-UserInfo subject binding
The flow is session-backed. Your authorization redirect route and callback route should run behind Laravel's web middleware so the package can keep:
statenoncecode_verifier- the session-scoped DPoP key
- the effective redirect URI
Installation
composer require ziming/laravel-myinfo-sg
Laravel discovers the service provider automatically. Publish the package configuration:
php artisan vendor:publish --provider="Ziming\LaravelMyinfoSg\LaravelMyinfoSgServiceProvider" --tag="myinfo-sg-config"
The FAPI 2.0 integration uses config/laravel-myinfo-sg-v5.php.
Configuration
Set your Singpass application's client ID, registered callback URI, and approved scopes. Scopes are
space-separated; include openid and the MyInfo scopes approved for your application. The example uses
the package's staging issuer default.
Generate your keys using Generate JWKS, then replace both JWKS placeholders below with the complete JSON contents of the generated files. These settings accept JSON strings, not file paths. Register the matching public JWKS or its public URL in your Singpass app configuration.
Environment variables
MYINFO_V5_ISSUER_URI=https://stg-id.singpass.gov.sg MYINFO_V5_CLIENT_ID=your-client-id MYINFO_V5_REDIRECT_URI=https://your-app.test/callback/myinfo-v5 MYINFO_V5_SCOPES=openid # Full private JWKS used for client assertion signing and decrypting ID token/userinfo responses MYINFO_V5_PRIVATE_JWKS='{"keys":[...]}' # Matching public JWKS exposed to Singpass MYINFO_V5_PUBLIC_JWKS='{"keys":[...]}' # Select the signing key from the private JWKS used for client assertions MYINFO_V5_CHOSEN_JWKS_SIG_KID=sig-your-key-id # Select the ephemeral DPoP signing profile (ES256, ES384, or ES512; defaults to ES256) MYINFO_V5_DPOP_SIGNING_ALG=ES256 # Outbound transport limits and safe-read retry policy MYINFO_V5_CONNECT_TIMEOUT_SECONDS=5 MYINFO_V5_REQUEST_TIMEOUT_SECONDS=15 MYINFO_V5_SAFE_READ_MAX_ATTEMPTS=2 MYINFO_V5_SAFE_READ_RETRY_DELAY_MILLISECONDS=200 # Optional package routes MYINFO_V5_ENABLE_DEFAULT_AUTHORIZATION_REDIRECT_ROUTE=false MYINFO_V5_CALL_AUTHORIZATION_API_URI=/redirect-to-singpass-v5 MYINFO_V5_ENABLE_DEFAULT_PUBLIC_JWKS_ENDPOINT_ROUTE=false MYINFO_V5_PUBLIC_JWKS_URI=/myinfo/v5/jwks MYINFO_V5_DEBUG_MODE=false
After changing environment values in a deployment that caches configuration, rebuild the cache with
php artisan config:cache. The generated key files are not loaded automatically by the package.
Redirect the user to Singpass
Enable MYINFO_V5_ENABLE_DEFAULT_AUTHORIZATION_REDIRECT_ROUTE=true to use the package's
POST /redirect-to-singpass-v5 route. Submit a Blade form with a CSRF token:
<form method="POST" action="{{ route('myinfo-v5.singpass') }}"> @csrf <button type="submit">Continue with Singpass</button> </form>
The route accepts POST requests, so a normal hyperlink or HTTP redirect to it will not work.
That route uses Ziming\LaravelMyinfoSg\Http\Controllers\MyinfoV5\CallAuthorizationApiController internally.
For a custom controller or route in routes/web.php, use the connector directly:
<?php use Ziming\LaravelMyinfoSg\Http\Integrations\MyinfoV5\MyinfoConnector; $myinfoConnector = new MyinfoConnector; return redirect()->to( $myinfoConnector->generateAuthorizationUrl() );
If you need to override the redirect URI for this request only:
<?php use Ziming\LaravelMyinfoSg\Http\Integrations\MyinfoV5\MyinfoConnector; $myinfoConnector = new MyinfoConnector; return redirect()->to( $myinfoConnector->generateAuthorizationUrl( 'https://your-app.test/callback/myinfo-v5' ) );
The package stores each authorization attempt as a separate, session-bound transaction for 10 minutes
by default. Adjust transaction_ttl_seconds in config/laravel-myinfo-sg-v5.php if needed.
That means starting another authorization in a second tab does not overwrite the first tab's state,
PKCE verifier, redirect URI, nonce, issuer, or DPoP key.
Handle the callback
Define your own callback route in routes/web.php, matching MYINFO_V5_REDIRECT_URI. Use completeAuthorization() followed by
getVerifiedUserInfo() as the secure completion flow. It validates and consumes the transaction-scoped
state, compares callback iss exactly, exchanges the code with that transaction's PKCE and DPoP
context, and verifies the ID token. The UserInfo request then reuses that same transaction DPoP key,
includes the access-token hash in ath, verifies the response, and requires its sub to match the
verified ID-token subject.
<?php use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; use Ziming\LaravelMyinfoSg\Http\Integrations\MyinfoV5\MyinfoConnector; Route::get('/callback/myinfo-v5', function (Request $request) { $myinfoConnector = new MyinfoConnector; $tokenSet = $myinfoConnector->completeAuthorization($request); $userInfo = $myinfoConnector->getVerifiedUserInfo($tokenSet); return response()->json($userInfo->personInfo()); });
Routes in routes/web.php already receive Laravel's web middleware. If you register these routes
elsewhere, apply it to both the authorization and callback routes so they share the same session.
The JSON response above is a minimal example; use personInfo() in your application's own workflow.
Verified responses
The returned VerifiedTokenSet exposes the access token through accessToken(), the verified ID-token
claims through claims(), the trusted subject through subject(), and the exact DPoP token type through
tokenType(). Its access token and transaction-bound private DPoP key are excluded from debug and JSON
output, and the object cannot be serialized.
getVerifiedUserInfo() returns a VerifiedUserInfo DTO. Its claims() method exposes the full verified
claim set, subject() exposes the subject that was matched to the ID token, and personInfo() returns the
typed person_info array. UserInfo requires person_info, iss, iat, sub, and aud. Its exp claim
is optional, but is validated when present.
The authorization nonce is verified only in the ID token. UserInfo does not carry or require a nonce;
it is bound to the authenticated session by matching its sub to the verified ID-token subject. Time-based
ID-token and UserInfo checks use a fixed two-second clock-skew allowance. Beyond that allowance, the current
time must be before any applicable exp, and iat must not be in the future.
Low-level compatibility methods
getAccessToken(string $code) and getAccessTokenFromValidatedCallback() remain available as low-level
compatibility methods. They return raw, unverified token-endpoint data. The string-only method also cannot
validate callback state or iss. Do not treat either result as authenticated or use either method as the
primary callback path in new integrations.
getUser(string $accessToken) is also a low-level compatibility method. It verifies the UserInfo signature
and required claim shapes through the shared processor, but a bare access-token string cannot prove that its
UserInfo sub matches a verified ID token. Use getVerifiedUserInfo($tokenSet) for subject-bound data.
Public JWKS endpoint
Set MYINFO_V5_ENABLE_DEFAULT_PUBLIC_JWKS_ENDPOINT_ROUTE=true to expose GET /myinfo/v5/jwks:
route('myinfo-v5.public-jwks')
That route uses Ziming\LaravelMyinfoSg\Http\Controllers\MyinfoV5\PublicJwksController and returns the value from MYINFO_V5_PUBLIC_JWKS.
The endpoint validates this configuration before building a response. It fails closed if the public JWKS
contains private d material, duplicate key IDs, unsupported algorithms or curves, or does not contain at
least one signing key and one encryption key. It never repairs a private JWKS by silently removing d.
If private key material may already have been served from this endpoint, rotate every affected signing or
encryption key and update the registered public JWKS. Correcting MYINFO_V5_PUBLIC_JWKS alone is not
sufficient because the exposed private key must be treated as compromised.
If you prefer to register the routes yourself:
<?php use Illuminate\Support\Facades\Route; use Ziming\LaravelMyinfoSg\Http\Controllers\MyinfoV5\CallAuthorizationApiController; use Ziming\LaravelMyinfoSg\Http\Controllers\MyinfoV5\PublicJwksController; Route::post('/redirect-to-singpass-v5', CallAuthorizationApiController::class) ->name('myinfo-v5.singpass') ->middleware('web'); Route::get('/myinfo/v5/jwks', PublicJwksController::class) ->name('myinfo-v5.public-jwks');
Errors and transport recovery
Handle callback and verification errors in your application's exception handler or callback controller:
Exception in Ziming\LaravelMyinfoSg\Exceptions\MyinfoV5 |
Meaning |
|---|---|
AuthorizationResponseException |
Singpass returned an authorization error; errorCode contains the provider error code. |
InvalidAuthorizationCallbackException |
Callback validation failed, for example because state expired or the issuer did not match. |
InvalidIdTokenException |
ID-token decryption or verification failed. |
InvalidUserInfoException |
UserInfo decryption, verification, or subject binding failed. |
MyinfoV5TransportException |
A connection failed or a retryable response exhausted the allowed attempts. |
Do not continue with personal data after verification fails. For an expired or consumed authorization transaction, offer the user a new authorization attempt through the POST form above.
Timeouts and retries
Every request uses the configured connection and overall request timeouts. Safe-read attempts are total attempts, including the first request, and must be between 1 and 3. The retry delay must be between 0 and 5,000 milliseconds.
| Endpoint | Attempts | Automatically retried failures |
|---|---|---|
| OpenID discovery | MYINFO_V5_SAFE_READ_MAX_ATTEMPTS |
Connection failures, 429, 502, 503, 504 |
| Singpass JWKS | MYINFO_V5_SAFE_READ_MAX_ATTEMPTS |
Connection failures, 429, 502, 503, 504 |
| UserInfo | MYINFO_V5_SAFE_READ_MAX_ATTEMPTS |
Connection failures, 429, 502, 503, 504; a fresh DPoP proof and jti are generated for every attempt |
| PAR | 1 | Never automatically retried |
| Token exchange | 1 | Never automatically retried |
Connection failures and exhausted retryable responses throw
Ziming\LaravelMyinfoSg\Exceptions\MyinfoV5\MyinfoV5TransportException. Its endpoint() method returns a
safe endpoint category, and restartAuthorization() tells the application how to recover. When
restartAuthorization() is true, the authorization outcome is ambiguous: discard that attempted flow
and start a new authorization. Never replay its old authorization code, client assertion, or DPoP proof.
When it is false and you already have a VerifiedTokenSet, you may retry UserInfo using that object
within the current request; the package will generate a new DPoP proof. The token set cannot be serialized
into a session or queued job. Do not retry completeAuthorization() with an already consumed callback.
use Ziming\LaravelMyinfoSg\Exceptions\MyinfoV5\MyinfoV5TransportException; use Ziming\LaravelMyinfoSg\Http\Integrations\MyinfoV5\MyinfoConnector; $myinfoConnector = new MyinfoConnector; try { $tokenSet = $myinfoConnector->completeAuthorization($request); $userInfo = $myinfoConnector->getVerifiedUserInfo($tokenSet); } catch (MyinfoV5TransportException $exception) { if ($exception->restartAuthorization()) { // Offer a new authorization attempt using the POST form in your UI. return response()->json(['message' => 'Please start Singpass authorization again.'], 503); } return response()->json(['message' => 'Singpass is temporarily unavailable.'], 503); }
Singpass discovery and JWKS responses remain normally cached for one hour. If ID-token or UserInfo verification finds an unknown signing key or a bad signature, the package invalidates the cached Singpass JWKS and refreshes it exactly once before returning the existing sanitized invalid-token error. Decryption, algorithm, nonce, claim, and subject failures do not trigger a JWKS refresh.
Generate JWKS
Generate the initial signing and encryption key pairs with:
php artisan myinfo:generate-jwks \
--private-output=storage/app/private/myinfo/private.jwks.json \
--public-output=storage/app/myinfo/public.jwks.json
Add --configure for a guided, step-by-step choice of the signing algorithm, encryption algorithm, and
encryption curve:
php artisan myinfo:generate-jwks --configure \
--private-output=storage/app/private/myinfo/private.jwks.json \
--public-output=storage/app/myinfo/public.jwks.json
For non-interactive scripts, pass the choices directly:
php artisan myinfo:generate-jwks \
--signing-alg=ES384 \
--encryption-alg=ECDH-ES+A192KW \
--encryption-curve=P-384 \
--private-output=storage/app/private/myinfo/private.jwks.json \
--public-output=storage/app/myinfo/public.jwks.json
Supported signing combinations:
| Signing algorithm | Curve |
|---|---|
ES256 (default) |
P-256 |
ES384 |
P-384 |
ES512 |
P-521 |
Supported encryption algorithms are ECDH-ES+A128KW (default), ECDH-ES+A192KW, and
ECDH-ES+A256KW. Each can be used with P-256 (default), P-384, or P-521, as permitted by the
Singpass JWKS requirements.
The private file is created with owner-only permissions (0600). Keep it outside the public web root and
store its contents in MYINFO_V5_PRIVATE_JWKS or an appropriate secrets manager. The public file contains
matching public keys without the private d property and can be used for MYINFO_V5_PUBLIC_JWKS or the
public JWKS endpoint. The command also prints the generated MYINFO_V5_CHOSEN_JWKS_SIG_KID value.
If --public-output is omitted, the public JWKS is printed as a ready-to-copy environment assignment.
Private key material is never printed unless you explicitly use --show-private; only use that option in a
trusted local terminal and never in CI or captured logs. Existing files are not overwritten unless --force
is supplied.
Validate the complete private/public pair before deployment, optionally checking the configured signing key selection:
php artisan myinfo:validate-jwks \
--private=storage/app/private/myinfo/private.jwks.json \
--public=storage/app/myinfo/public.jwks.json \
--signing-kid="$MYINFO_V5_CHOSEN_JWKS_SIG_KID"
Select the DPoP signing profile
MYINFO_V5_DPOP_SIGNING_ALG independently selects the algorithm for the ephemeral DPoP key. It does
not select the registered client-assertion key controlled by MYINFO_V5_CHOSEN_JWKS_SIG_KID.
| DPoP algorithm | Required curve |
|---|---|
ES256 (default) |
P-256 |
ES384 |
P-384 |
ES512 |
P-521 |
The algorithm determines the curve; there is no separate DPoP curve setting. Discovery metadata may reject the selected local profile but cannot enable any profile outside this table.
The package generates a fresh ephemeral DPoP private key for every authorization transaction. That exact
key and algorithm are retained for the transaction and reused across PAR, token exchange, and UserInfo,
even if configuration changes after PAR. Every HTTP request still receives a newly signed proof with a
fresh jti; only the UserInfo proof includes the access-token hash in ath. DPoP keys are never reused
between transactions.
Rotate JWKS
Rotate signing and encryption keys at least annually. The guided rotation command always reads a complete, validated pair and creates new complete JWKS files at distinct paths; it never overwrites its inputs.
These commands only produce local artifacts. They do not publish or deploy a JWKS, update .env, select a
signing key, contact Singpass, or update a partner portal. Put private output into a secrets manager. Only the
public output belongs at the registered public JWKS endpoint.
For signing-key rotation, prepare an old+new signing-key overlap (replace the example kid values and paths):
php artisan myinfo:rotate-jwks \
--stage=prepare \
--role=signing \
--replace-kid=sig-old-kid \
--private-input=storage/app/private/myinfo/private.jwks.json \
--public-input=storage/app/myinfo/public.jwks.json \
--private-output=storage/app/private/myinfo/signing-overlap.private.jwks.json \
--public-output=storage/app/myinfo/signing-overlap.public.jwks.json
Deploy the prepared private overlap to the secrets manager while keeping the old signing kid selected.
Publish the prepared public set containing the old and new signing keys, wait at least one hour for the
Singpass JWKS cache, and then change MYINFO_V5_CHOSEN_JWKS_SIG_KID to the new kid printed by the command.
After the new signing key is active, retire the old key into another new pair:
php artisan myinfo:rotate-jwks \
--stage=finalize \
--role=signing \
--replace-kid=sig-old-kid \
--active-signing-kid=sig-new-kid \
--confirm-cache-expired \
--private-input=storage/app/private/myinfo/signing-overlap.private.jwks.json \
--public-input=storage/app/myinfo/signing-overlap.public.jwks.json \
--private-output=storage/app/private/myinfo/signing-final.private.jwks.json \
--public-output=storage/app/myinfo/signing-final.public.jwks.json
For encryption-key rotation, prepare a private old+new overlap and a public set containing only the new encryption key (unrelated keys are retained):
php artisan myinfo:rotate-jwks \
--stage=prepare \
--role=encryption \
--replace-kid=enc-old-kid \
--private-input=storage/app/private/myinfo/private.jwks.json \
--public-input=storage/app/myinfo/public.jwks.json \
--private-output=storage/app/private/myinfo/encryption-overlap.private.jwks.json \
--public-output=storage/app/myinfo/encryption-new.public.jwks.json
Deploy the private overlap first so responses encrypted to either key can be decrypted. Then publish the new public set, wait at least one hour, and finalize removal of the old private encryption key:
php artisan myinfo:rotate-jwks \
--stage=finalize \
--role=encryption \
--replace-kid=enc-old-kid \
--confirm-cache-expired \
--private-input=storage/app/private/myinfo/encryption-overlap.private.jwks.json \
--public-input=storage/app/myinfo/encryption-new.public.jwks.json \
--private-output=storage/app/private/myinfo/encryption-final.private.jwks.json \
--public-output=storage/app/myinfo/encryption-final.public.jwks.json
Without --confirm-cache-expired, finalize asks interactively whether the one-hour cache window elapsed.
Non-interactive deployment pipelines must pass the flag explicitly; it records operator confirmation and
does not attempt to infer deployment history.
The required state transitions are:
- Signing: publish old+new public keys, wait one hour, switch the signing
kid, then retire the old public and private key. - Encryption: retain old+new private keys, publish the new public key, wait one hour, then retire the old private key.
Development
From the package repository:
composer install
composer test
composer analyse
Use vendor/bin/testbench instead of php artisan when running the package's JWKS commands directly
from this repository.
Contributions are welcome. See CONTRIBUTING for contribution guidelines and CHANGELOG for release history.