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
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
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:
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.
- Keep the current public key. Copy
storage/oauth-public.keytostorage/oauth-previous-public.key, or setSSO_PREVIOUS_PUBLIC_KEYto it. - Replace the pair, with
php artisan passport:keys --forceor newPASSPORT_PRIVATE_KEYandPASSPORT_PUBLIC_KEYvalues. - Restart, and clear caches.
- 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_hintsigned 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_KEYrather 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.
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:
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
* * * * * 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.
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.
// 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 |
|---|---|
| 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.
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.