Laravel SSO Laravel SSO

Deployment

Running it in production: the queue, the scheduler and the signing keys.

On this page 11

This is an ordinary Laravel application. Anything that runs Laravel in production runs this: Laravel Cloud, Forge, a container, a plain VPS.

Two things make it different from a typical application, and both are about the fact that other systems depend on it: the issuer must be stable, and the signing keys must survive.

Before you go live

bash
composer install --no-dev --optimize-autoloader
npm ci && npm run build

php artisan migrate --force
php artisan sso:install --skip-migrations
php artisan storage:link

php artisan optimize

storage:link is what makes an uploaded logo or sign-in background reachable. Without it they resolve to nothing. sso:install deliberately does not do it for you, since it writes outside the application's own directories.

optimize caches the configuration, routes, events and views, and rebuilds the cached documentation. Run it on every deployment, not only the first. The documentation keeps its list of pages for a day, so a page added by a release is missing from /docs until it runs.

Check the installer's output. It ends with warnings for the mistakes that are easy to make and expensive to discover later.

Environment

env
APP_ENV=production
APP_DEBUG=false
APP_URL=https://auth.example.com

SSO_ISSUER=https://auth.example.com

APP_DEBUG=false matters more here than in most applications: a stack trace from this server can contain client identifiers, token payloads and query parameters from an authorization request.

If you terminate TLS at a proxy, configure TrustProxies. Otherwise Laravel generates http:// URLs, the redirect back from /oauth/authorize is downgraded, and the audit trail records your load balancer's address as every user's IP.

Signing keys

storage/oauth-private.key signs every access token and ID Token. storage/oauth-public.key is what relying parties fetch from /.well-known/jwks.json to verify one.

They are not in version control, and sso:install will never overwrite an existing pair.

Back them up, encrypted, separately from the database. Losing the private key means every application must re-verify against a new one. Leaking it means anyone can mint an ID Token that your applications will believe, the same severity as leaking your database and harder to notice.

On a platform with an ephemeral filesystem, every instance must sign with the same pair. Generate it once, then either put both files on a shared volume, or hand the keys to every instance through the environment:

bash
PASSPORT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQ...\n-----END PRIVATE KEY-----\n"
PASSPORT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMIIBIj...\n-----END PUBLIC KEY-----\n"

A literal \n stands for each line break, so a key fits on one line. When the variables are set they win over the files for access tokens, ID Tokens and the key set alike, and sso:install does not generate key files.

Otherwise the installer generates a new pair for each instance, and instances with different keys sign tokens that the others' published key set cannot verify.

Rotating

Every ID Token carries a kid header naming the key that signed it, and the key set can publish the key you retire next to the active one, as OpenID Connect Core 1.0 section 10.1.1 recommends. A client that meets a kid it does not recognise is expected to fetch the key set again, where it finds both keys and goes on verifying tokens signed before the rotation.

  1. Keep the current public key. Copy storage/oauth-public.key to storage/oauth-previous-public.key, or set SSO_PREVIOUS_PUBLIC_KEY to it.
  2. Replace the pair, with php artisan passport:keys --force or new PASSPORT_PRIVATE_KEY and PASSPORT_PUBLIC_KEY values.
  3. Restart, and clear caches.
  4. Once the longest ID Token lifetime has passed (15 minutes by default), delete the previous key and restart again. Waiting a day costs nothing.

What the retained key covers, and what it does not:

  • ID Tokens signed before the rotation keep verifying at every client, and an id_token_hint signed by the retired key still signs a user out straight away. Once the key is removed, such a hint asks the user first.
  • Access tokens signed before the rotation are refused by this server's own UserInfo and introspection endpoints, which verify with the active key alone. Their refresh tokens are encrypted with APP_KEY rather than signed, so a client that refreshes gets a new access token without the user signing in again.
  • Nobody is signed out of this server. The browser session does not depend on the signing key.

Each of these is pinned by tests/Feature/Oidc/KeyRotationTest.php.

Rotate when you have reason to, such as a suspected leak or a departing administrator with server access, not on a routine schedule.

Mail

Verification and password-reset messages are sent with Laravel's own notifications, synchronously, on the request that triggers them. Configure a real MAIL_MAILER before you enable email verification: with the default log mailer the messages go to storage/logs, and a user who cannot verify their address cannot sign in.

If you would rather not have a slow SMTP host stall a request, queue those notifications and run a worker:

bash
php artisan queue:work --tries=3

Run it under a supervisor that restarts it, and restart it on every deploy, because workers hold the old code in memory.

Scheduler

code
* * * * * cd /path/to/app && php artisan schedule:run >> /dev/null 2>&1

It runs activitylog:clean daily, which enforces the audit retention setting. SSO_AUDIT_RETENTION_DAYS is only its default. Without the scheduler the audit table grows forever, and it is the fastest-growing table in the schema, gaining a row on every sign-in and every failed sign-in.

Add passport:purge to remove revoked and expired tokens if you want that table bounded too.

Sessions

SESSION_DRIVER=database is the default, and the Sessions page depends on it: with any other driver the page reports that sessions are not stored where it can see them, and administrators cannot end one. Redis is fine for performance, at the cost of that page.

Database

SQLite is fine for a small deployment and is what the defaults use. For anything with more than one application server, use MySQL or PostgreSQL, because SQLite cannot be shared across hosts.

Back it up. It holds your users, your applications' hashed secrets, your access grants and your audit trail.

Health

/up returns 200 when the framework boots. Point your monitor at /.well-known/openid-configuration as well: it exercises the configuration your relying parties actually depend on, and it is the first thing to break when the issuer is misconfigured.

Hosting a public demo

A demonstration server is signed into by strangers, and everything they can reach they can also change. demo:reset drops the database and rebuilds the sample data, so what a visitor finds is the demo rather than whatever the last visitor left behind.

This is lauroguedes/laravel-demo-mode, which ships with this project. Its own documentation covers every setting; what follows is how this project uses it.

env
DEMO_MODE=true
DEMO_RESET_SCHEDULE="0 */6 * * *"

With DEMO_MODE on, the scheduler runs the reset on that cycle. Run it by hand with php artisan demo:reset, and see the plan without touching anything with php artisan demo:reset --dry-run.

Before the first scheduled reset, run php artisan demo:doctor. It audits the whole configuration and exits non-zero on anything that would destroy data or publish a secret — a host that does not match, a credentials disk the web server would serve, a seeder that does not exist. It belongs in the deploy pipeline, ahead of the reset.

php
// config/demo.php — the two settings nothing can infer for you
'environments'  => ['demo'],
'allowed_hosts' => ['demo.example.com'],

allowed_hosts is the only barrier that checks what the internet actually resolves rather than a convention, which makes it the one that survives an .env reaching a server it should not have. Set it on anything public.

The same switch closes what a stranger could otherwise abuse:

On a demo What happens
Mail Nothing is sent. The transport is replaced, so no message reaches an address a visitor typed
Email verification Forced off and pinned, so nobody is stranded behind a message that will never arrive
The administrator's record Cannot be edited, so one visitor cannot lock out the next by changing the published address
The administrator's password Regenerated on every reset, so what the last visitor wrote down stops working
The sign-in page Fills that administrator in, since a demo nobody can enter is not a demo
The documentation Readable without signing in. Everywhere else, /docs asks the visitor to sign in first

The published credentials live in storage/app/private/demo-credentials.json, written only while DEMO_MODE is on and read back only while it still is — so a leftover file on a server that stopped being a demo reads as nothing at all. The other demo accounts keep the seeder's shared password. php artisan demo:credentials prints the current one.

Email verification is the one thing on that list this project decides for itself, in config/sso.php; everything else is config/demo.php.

Warning

This deletes every user, application and token. It refuses to run unless DEMO_MODE is on, unless APP_ENV is one of demo.environments, and unless the request host is one of demo.allowed_hosts when that is set. It refuses in production regardless, because the seeder it runs creates accounts with a password anyone can look up. Never turn it on for an installation holding anything you want to keep.

Scaling

The server is stateless apart from the database and the signing keys, so it scales horizontally as long as every instance has:

  • the same signing keys,
  • the same SSO_ISSUER,
  • a shared session store,
  • a shared cache.

Rate limits are counted per instance unless the cache is shared.