August 5, 2026

The provider: turning a Laravel app into an identity provider

By Manuel Christlieb — Staff Engineer

Part 2 of the Be Your Own Identity Provider series. Part 1 covered the why.

Laravel already has an OAuth2 authorization server: Passport. So the obvious question when I started was whether an OIDC package needed to be anything more than a thin shim on top of it.

It does. But the interesting part is which parts.

What Passport gives you, and what it doesn’t

Passport implements OAuth2 properly — clients, the authorization code grant, refresh tokens, scopes, token storage. That’s the hard, boring, security-critical half, and reimplementing it would have been a great way to discover which RFC footnotes I’d never read.

What it doesn’t have is everything from the identity half of part 1:

  • no id_token — Passport issues access tokens, and an access token is not a statement about a user
  • no discovery document, so no client can configure itself from your issuer URL
  • no JWKS endpoint, so nobody can verify your signatures without out-of-band setup
  • no userinfo, no end-session endpoint, no back-channel logout
  • no OIDC scopes and claims, no nonce handling, no max_age

None of those are things you bolt on from the outside, because most of them have to be woven into the authorization request itself. Which leads to the one decision the whole package is built on.

Taking over the routes

On registration, the package calls Passport::ignoreRoutes() and registers the entire /oauth/* surface itself, from a single config map — config('oidc.handlers'), one entry per endpoint:

EndpointRoute
DiscoveryGET /.well-known/openid-configuration
JWKSGET /.well-known/jwks.json
AuthorizeGET /oauth/authorize
TokenPOST /oauth/token
UserInfoGET|POST /oauth/userinfo
End sessionGET|POST /oauth/logout
IntrospectionPOST /oauth/introspect
RevocationPOST /oauth/revoke

Owning the routes is what makes the rest possible: the authorize controller can handle nonce, max_age and the id_token response type; the access-token entity is swapped for one that knows about OIDC; the token endpoint returns an id_token alongside the access token. Each entry can also be pointed elsewhere or switched off — disable userinfo and it disappears from discovery too, so the document never advertises something that isn’t there.

Two consequences worth knowing before you install it. Passport’s optional JSON management routes (client CRUD, personal access tokens) are not registered — register them yourself if you use them. And:

PKCE is required on every authorization request. Not just for public clients — for confidential ones as well, per OAuth 2.1 §4.1.1/§7.6. A request without a code_challenge is rejected with invalid_request. This will annoy exactly one integration you own, and then it will never be a problem again.

Tokens that resource servers can read

Access tokens are structured JWTs per RFC 9068: "typ": "at+jwt" in the header, a kid matching the JWKS endpoint, and the usual iss / aud / sub / client_id / scope claims. A resource server can verify and authorize a request against the JWKS alone, without calling back to the provider.

That is also where the MCP thread from part 1 gets picked up. The package implements RFC 8693 token exchange, which trades one token for another scoped to a different audience — the shape you want when a session in one app needs to call an API (or an MCP server) that must only accept tokens minted for it. There’s a CheckAudience middleware on the resource-server side to enforce the other end of that.

Signing keys live in environment variables by default — OIDC_PRIVATE_KEY / OIDC_PUBLIC_KEY, generated and rotated by php artisan oidc:rotate-keys, with the previous public key kept in JWKS during the overlap so rotation doesn’t invalidate tokens in flight. No key files on disk, and a SigningKeyStore seam if you’d rather keep them somewhere else.

The second layer: an auth engine, without views

The protocol layer above is usable on its own if you already have authentication. But /oauth/authorize has to put something in front of a user who isn’t logged in yet, and that’s where most “just add OIDC” projects quietly turn into “rewrite your auth”.

So the package ships a complete auth engine — login, registration, password reset, email verification, password confirmation, and multi-factor (TOTP, recovery codes, passkeys) — with all the logic inside the package and two kinds of seam for your app to fill. All of it runs through a dedicated identity session guard, shared with the authorization flow, so the provider and the engine can’t disagree about who is logged in.

The first seam is views. Every screen renders through a typed contract with a single respond() method:

interface LoginView
{
    public function respond(LoginPrompt $prompt, Request $request): Responsable|Response;
}

Eight of those cover every auth surface, consent included. The package deliberately ships no default implementations — an unbound contract throws MissingAuthViewException rather than rendering some Blade view I picked for you. The prompt carries the page-specific data; what it looks like is entirely yours.

The second seam is actions: the handful of decisions that are genuinely application-specific, like how a user gets created on registration. Plus a post-login pipeline with one decision hook — require MFA, deny, or add claims — which is also where acr and amr come from.

I’ll leave the engine there; MFA and the post-login pipeline deserve their own writeup rather than a paragraph.

Next up: the other side of the protocol — the relying party, and why logging in through an OIDC provider is a separate package on purpose.