This document collects security guidance specific to integrating
stromcom/auth-client into a web application or backend service.
The SDK uses RFC 7636 PKCE with code_challenge_method=S256 by default. The
code_verifier is 32 bytes of random_bytes base64url-encoded (43 chars,
matching the RFC's 43–128 character range with the highest entropy that
keeps a single AES block).
You don't have to think about this — beginAuthorization() generates a
fresh PKCE pair on every call and returns the verifier so you can store it.
Don't reuse verifiers across flows.
beginAuthorization() returns a 32-character hex state. Store it in your
session and compare with the value the auth server echoes back in the
callback with hash_equals (timing-safe).
if (!hash_equals($_SESSION['oauth_state'] ?? '', $_GET['state'] ?? '')) {
exit('CSRF');
}Don't use == or === — both leak comparison timing.
The client secret is functionally a password. Treat it accordingly:
- Read from a secret manager (SSM Parameter Store, AWS Secrets Manager, Vault, GitHub Actions secrets, …).
- Never commit it to git.
- Never log it.
- Never send it to the browser.
- Never expose it in error responses.
Public clients (SPAs without a backend, mobile apps without a backend)
should not use a client secret at all — they use PKCE alone. The auth
server supports public clients via the --public flag at client creation.
The access token is short-lived (default 15 min) and bearer — anyone with it impersonates the user. Storage rules:
| Storage location | Verdict |
|---|---|
HttpOnly; Secure; SameSite=Lax cookie |
✅ Recommended for web apps |
| In-memory (SPA, never persisted) | ✅ Recommended for SPAs |
localStorage / sessionStorage |
❌ Readable by XSS |
Plain (non-HttpOnly) cookie |
❌ Readable by XSS |
| URL query string | ❌ Logged everywhere |
The refresh token (longer-lived, default 14 days) is even more sensitive:
| Storage location | Verdict |
|---|---|
HttpOnly; Secure; SameSite=Strict; Path=/oauth/refresh cookie |
✅ Recommended |
| Encrypted at rest in a server-side DB keyed by session ID | ✅ For SPAs |
| Anywhere readable by JS | ❌ Catastrophic |
The auth server rotates refresh tokens — every successful refresh returns a new refresh token and invalidates the old one. If a refresh token leaks and is used by the attacker before the user's next refresh, the user's next refresh will fail (the server detects token-family compromise) and force a re-login.
Service tokens have no refresh component — they live for 1 hour and are re-issued on demand. Keep them in memory only. Don't write them to disk.
If you cache them (see
examples/service-account-cached.php),
either:
- Keep them in process memory (in-memory cache, APCu, …).
- Or use an encrypted backend (e.g. Redis with TLS to a private VPC).
Never persist plain service tokens to a shared filesystem.
The auth server enforces exact-match on redirect_uri. Implications:
https://app.example.com/cbandhttps://app.example.com/cb/(trailing slash) are different URIs and must be whitelisted separately if you need both.- The query string is not part of the comparison and must not be used
to encode state — use the
stateparameter. http://localhost:*works for local dev; production must behttps://.
Client::logoutUrl() builds a URL that drops the auth server's SSO
session cookie. It does not revoke access tokens or refresh tokens.
After logout:
- The user's next call to
/oauth/authorizewill re-prompt for credentials. - Tokens you've already issued remain valid until their
exp.
If you need immediate revocation:
- Clear your own access_token and refresh_token cookies.
- Optionally maintain a small in-process blocklist of
jtivalues from explicitly logged-out tokens, checked in yourverify()wrapper.
There is intentionally no token revocation endpoint exposed by the auth server — JWT bearer model assumes tokens are short-lived.
The SDK fetches JWKS over HTTPS to the auth server's issuer URL. Trust anchor is the system's CA store via cURL.
Don't disable TLS verification on CurlHttpClient. If you need to
override it for development against a self-signed cert, write a tiny
adapter to HttpClientInterface that does so — don't add a config flag
that could ship to production.
The SDK accepts only RS256. Any other alg in the JWT header is
refused. This includes:
none— the classic "no algorithm" attack.HS256/HS384/HS512— symmetric algorithms that could let an attacker who knows the public key forge tokens.ES256and friends — not used by the server.
This is intentional and should not be relaxed.
The verifier accepts ±30 seconds of skew by default (Configuration::$leeway).
For very large fleets with potentially poor NTP discipline, you may set this
up to ~120 seconds. Anything higher than that should prompt you to fix NTP
instead.
When logging auth-related events, log only:
claims.subject(the user/client ID — already public per the JWT)claims.jti(the token's unique identifier, useful for audit correlation)exception classandmessage
Never log:
- The JWT itself
- The refresh token
- The client secret
- Tokens received from downstream APIs
Security issues should go directly to security@stromcom.cz (or the
project's GitHub Security tab — never a public issue).