A small, zero-dependency, framework-agnostic OpenID Connect (OIDC) library for PHP. Drop-in authentication with a single auto-checking call — no user clicks unless you want a button.
use Sclemance\Oidc\Oidc;
$oidc = new Oidc([
'issuer' => 'https://login.microsoftonline.com/<tenant-id>/v2.0',
'client_id' => '<client-id>',
'client_secret' => '<client-secret>',
'redirect_uri' => 'https://app.example.com/callback.php',
]);
$user = $oidc->requireAuth(); // signed-in users pass straight through (SSO, no clicks)
echo 'Hello ' . htmlspecialchars($user->name() ?? $user->email());Most OIDC libraries make you wire up routes, sessions, token validation, and a callback
controller before anything works. php-oidc collapses that to one call: requireAuth()
handles the redirect out to the provider, the callback back, ID-token validation, and the
session — so a page is protected in one line. Works with any standards-compliant provider:
Microsoft Entra ID (Azure AD), Google, Okta, Auth0, Keycloak, Ping, and others.
- Authorization Code flow + PKCE (S256), the current best practice.
- Automatic:
requireAuth()needs no user interaction; silent SSO just works. Add a button only if your app wants one (login()/logout()). - Full ID-token validation: RS256/384/512 signature via JWKS, plus
iss/aud/exp/nbf/noncechecks and a CSRFstatecheck. - Zero runtime dependencies — only
ext-openssl+ext-json. No Composer required (a plainautoload.phpis included).ext-curloptional; falls back to PHP streams. - Discovery + JWKS caching (pluggable; file cache by default).
- Pluggable session & cache (implement an interface to use your framework's).
- Authorization policy hook to restrict who may sign in (domain, group, roles…).
- Provider single-logout (RP-initiated) when the provider supports it.
- PHP 8.1+
ext-openssl,ext-json- Either
allow_url_fopenenabled (default) orext-curl
composer require sclemance/php-oidcrequire 'vendor/autoload.php';Copy the repo somewhere and require the bundled autoloader:
require '/path/to/php-oidc/autoload.php';Register the page's own URL as the redirect URI. See examples/protect-page.php.
$user = $oidc->requireAuth(); // starts login AND handles the callback on this same URLRegister one callback.php as the redirect URI. Protected pages call requireAuth(); the
provider always returns to callback.php, which finishes login and bounces the user back.
See examples/callback.php + examples/bootstrap.php.
Don't force login on arrival — show a button. See examples/login-logout.php.
if ($_GET['do'] ?? '' === 'login') $oidc->login('/account.php');
if ($_GET['do'] ?? '' === 'logout') $oidc->logout('/');
$user = $oidc->user(); // null when signed out (never redirects)Attempt SSO without ever showing a login screen; decide yourself what to do if there's no session:
// On a protected page, trigger a one-time silent attempt:
$oidc->login($returnTo, ['prompt' => 'none']);
// In your callback, a failed silent attempt arrives as an OAuth error:
try { $oidc->handleCallback(); }
catch (Sclemance\Oidc\Exception\AuthenticationException $e) {
if ($e->oauthError === 'login_required') { /* show public page or a Sign-in button */ }
}authorize runs after authentication; return false to deny (throws AuthenticationException):
'authorize' => fn(array $claims) =>
str_ends_with($claims['email'] ?? '', '@example.com'), // domain allow-list
// or: in_array('<group-object-id>', $claims['groups'] ?? [], true),The one-liner covers most apps, but nothing is hidden from you.
Render your own failure page. requireAuth() throws AuthenticationException for a bad
state, a provider error, or a rejected authorize policy — catch it and show whatever you
want:
use Sclemance\Oidc\Exception\AuthenticationException;
try {
$user = $oidc->requireAuth();
} catch (AuthenticationException $e) {
http_response_code(403);
// $e->oauthError holds the provider's error code (e.g. 'access_denied') when present.
require __DIR__ . '/views/access-denied.php';
exit;
}Control the redirects yourself (no header()/exit from the library) using the
primitives — handy inside a framework or when you want an interstitial:
$url = $oidc->getAuthorizationUrl($returnTo); // build URL + stash transaction; you redirect
$user = $oidc->handleCallback(); // exchange + validate at your callback route
$user = $oidc->user(); // current user or null (no redirect)
$url = $oidc->getLogoutUrl($returnTo, $idHint); // provider end-session URL (or null); you redirect
$oidc->forgetUser(); // clear the local session onlyHarden the session for sensitive apps (all opt-in):
'store_tokens' => false, // don't persist tokens if you don't need them post-login
'session_idle_ttl' => 1800, // re-auth after 30 min idle
'session_absolute_ttl' => 28800, // re-auth 8 h after login regardless of activityNote (by design): auth is session-based — there's no back-channel logout or live revocation check, so a local session stays valid until it (or a timeout) expires. Pair a timeout with your provider's Conditional Access for tighter control.
| Key | Required | Default | Description |
|---|---|---|---|
issuer |
yes* | — | Provider issuer URL; discovery is derived as <issuer>/.well-known/openid-configuration. |
discovery_url |
yes* | — | Explicit discovery URL (alternative to issuer). |
client_id |
yes | — | Application/client ID. |
client_secret |
no | — | Client secret. Omit for a public client (PKCE only). |
redirect_uri |
yes | — | Must exactly match a redirect URI registered with the provider. |
scopes |
no | ['openid','profile','email'] |
Array or space-separated string; openid is always included. |
pkce |
no | true |
Use PKCE S256. |
verify_signature |
no | true |
Verify the ID-token signature against JWKS. |
leeway |
no | 60 |
Clock-skew tolerance (seconds) for time-based claims. |
store_tokens |
no | true |
Persist the access/refresh/ID tokens in the session. Set false to keep only claims (smaller attack surface); tokens are still returned by handleCallback()/requireAuth() for use during that request. |
session_idle_ttl |
no | 0 (off) |
Re-authenticate after this many seconds of inactivity. |
session_absolute_ttl |
no | 0 (off) |
Re-authenticate this many seconds after login, regardless of activity. |
authorize |
no | — | callable(array $claims): bool — return false to deny. |
auth_params |
no | [] |
Extra authorization-request params (e.g. ['domain_hint'=>'acme.com']). |
post_logout_redirect_uri |
no | — | Where the provider returns after single-logout. |
session |
no | PhpSessionStore |
A SessionStoreInterface implementation. |
cache |
no | FileCache |
A CacheInterface for discovery/JWKS. |
cache_ttl |
no | 3600 |
Discovery/JWKS cache lifetime (seconds). |
* Provide either issuer or discovery_url.
// Microsoft Entra ID (single tenant)
'issuer' => 'https://login.microsoftonline.com/<tenant-id>/v2.0',
// Google
'issuer' => 'https://accounts.google.com',
// Okta
'issuer' => 'https://<your-domain>.okta.com',
// Auth0
'issuer' => 'https://<your-tenant>.us.auth0.com/',
// Keycloak
'issuer' => 'https://<host>/realms/<realm>',You need an app registration with a redirect URI and a client secret. Pick the automated script (fastest) or the portal steps.
A ready-to-run script using the current Microsoft Graph PowerShell SDK is included at
scripts/provision-entra.ps1. It creates the registration,
adds a secret, requests the email ID-token claim, and prints a paste-ready PHP config block.
# PowerShell 7+. You'll be prompted to sign in (needs Application Developer or higher).
./scripts/provision-entra.ps1 `
-DisplayName "Acme Intranet - OIDC" `
-RedirectUri "https://intranet.acme.com/callback.php" `
-CreateServicePrincipalIt installs Microsoft.Graph.Applications on first run and connects with the
Application.ReadWrite.All scope. Copy the printed client secret immediately — Entra shows
it only once.
By default the app is set to require user assignment — only users/groups you assign can
sign in, which is the more secure stance. Pass -NoAssignmentRequired to leave it open to any
user in the tenant. To grant access to a specific security group, add -AssignGroup:
./scripts/provision-entra.ps1 `
-DisplayName "Acme Intranet - OIDC" `
-RedirectUri "https://intranet.acme.com/callback.php" `
-AssignGroup "Acme Staff"-AssignGroup takes a group display name or object id and is best-effort: group-based app
assignment requires Entra ID P1 or higher. On the free tier the script warns and continues —
assign individual users in the portal instead (Enterprise applications → your app → Users and
groups). This is enforced by Entra (unassigned users never get a token) and is independent of
php-oidc's own authorize closure, which runs in your app after sign-in; the two complement
each other.
Once the app exists, scripts/update-entra.ps1 changes its redirect
URIs and rotates the client secret without recreating the registration. Locate the app by
-ClientId (preferred) or -DisplayName.
# Add a redirect URI and rotate the secret, keeping the old one alive (zero-downtime).
./scripts/update-entra.ps1 -DisplayName "Acme Intranet - OIDC" `
-AddRedirect "https://intranet.acme.com/callback.php" -RenewSecret
# Rotate the secret and remove the previous one.
./scripts/update-entra.ps1 -ClientId <client-id> -RenewSecret -PruneOldSecrets
# Just inspect current redirect URIs and secret expiry.
./scripts/update-entra.ps1 -DisplayName "Acme Intranet - OIDC"By default a renewed secret is added alongside the existing one so nothing breaks mid-deploy;
add -PruneOldSecrets to remove the old secret once the new one is live. Use
-ReplaceRedirects to swap the whole redirect set, or -RemoveRedirect to drop one. When
anything changes it prints an updated paste-ready config block. Supports -WhatIf for a dry run.
It can also rename the app (-RenameDisplayName, which also renames the enterprise app) and
manage the assignment gate on an existing app: -AssignGroup / -RemoveGroup,
-AssignMember / -RemoveMember (individual users), -ClearAssignments, and
-RequireAssignment / -NoAssignmentRequired (the enterprise app is created if needed):
# Restrict an existing app to a security group (also requires assignment).
./scripts/update-entra.ps1 -DisplayName "Acme Intranet - OIDC" -AssignGroup "Acme Staff"
# Remove a group's access and rename the app.
./scripts/update-entra.ps1 -DisplayName "Acme Intranet - OIDC" `
-RemoveGroup "Acme Contractors" -RenameDisplayName "Acme Portal - OIDC"
# Assign / remove individual users.
./scripts/update-entra.ps1 -DisplayName "Acme Intranet - OIDC" `
-AssignMember alice@acme.com,bob@acme.com -RemoveMember carol@acme.comFree tier without P1? Use -AssignMembersOf to assign a group's members individually
(the per-user assignment that free tenants allow), which sidesteps the P1 requirement of
-AssignGroup. It flattens nested groups (transitive) but is a point-in-time snapshot — it
doesn't track later membership changes, so re-run it to reconcile. Pair with -ClearAssignments
to reset first:
./scripts/update-entra.ps1 -DisplayName "Acme Intranet - OIDC" `
-ClearAssignments -AssignMembersOf "Acme Staff"Unlike provisioning, update-entra touches the access posture only when you pass one of the assignment options — a plain secret rotation, rename, or redirect change never alters who can sign in. Within one run the order is: clear → removals → additions.
TENANT=$(az account show --query tenantId -o tsv)
APP_ID=$(az ad app create \
--display-name "Acme Intranet - OIDC" \
--sign-in-audience AzureADMyOrg \
--web-redirect-uris "https://intranet.acme.com/callback.php" \
--query appId -o tsv)
# Client secret (valid 1 year):
SECRET=$(az ad app credential reset --id "$APP_ID" --years 1 --query password -o tsv)
# Optional: create the enterprise app (service principal)
az ad sp create --id "$APP_ID"
echo "issuer: https://login.microsoftonline.com/$TENANT/v2.0"
echo "client_id: $APP_ID"
echo "client_secret: $SECRET"- Sign in to the Microsoft Entra admin center as at least an Application Developer.
- Go to Entra ID → App registrations → New registration.
- Name it (e.g. Acme Intranet - OIDC).
- Supported account types: choose Single tenant only – <your tenant>.
- Redirect URI: select platform Web, and enter your callback URL
(e.g.
https://intranet.acme.com/callback.php). Then Register.- To add or change it later: Manage → Authentication → Add a platform → Web.
- On the Overview page, copy the Application (client) ID and Directory (tenant) ID. The Endpoints button shows the OIDC metadata document URL (your discovery URL).
- Manage → Certificates & secrets → Client secrets → New client secret. Copy the Value now (shown once).
- (Recommended) Manage → Token configuration → Add optional claim → ID →
emailso$user->email()is populated.openid,profile,emailscopes need no admin consent.
Then plug the values in:
$oidc = new Oidc([
'issuer' => "https://login.microsoftonline.com/$tenantId/v2.0",
'client_id' => '<client-id>',
'client_secret' => '<client-secret>',
'redirect_uri' => 'https://intranet.acme.com/callback.php',
]);Multiple apps? Register one app per web app (each with its own redirect URI + secret). The library reads it all from config — nothing app-specific is baked in.
- Uses Authorization Code + PKCE;
state(CSRF) andnonce(replay) are enforced. - The ID-token signature is verified against the provider's JWKS by default; the session id is regenerated on login (fixation defense); session cookies are HttpOnly/SameSite=Lax and Secure over HTTPS.
- Always serve over HTTPS and register HTTPS redirect URIs (localhost excepted for dev).
- Keep secrets out of the web root. Tokens are stored server-side in the session by default.
requireAuth() → if a session user exists, return it. If the current request is the provider
callback (matching state + code/error), exchange the code at the token endpoint, validate
the ID token (JWKS signature + claims + nonce), run your authorize policy, store the session,
and redirect back to the originating URL. Otherwise, redirect to the provider's authorization
endpoint. Discovery and JWKS are fetched once and cached.
The crypto and full flow are covered by offline tests that spin up a mock provider and use a locally-generated RSA key (no network, no real IdP):
composer test # or: php tests/run.phpMIT © Stan Clemance. See LICENSE.