JWT Authentication
Bemba authenticates API calls with JWT tokens (LexikJWTAuthenticationBundle) and renews the session with refresh tokens stored in the database (Gesdinet).
Tokens
| Token | Lifetime | Where it travels |
|---|---|---|
| Access token (RSA-signed JWT) | 1 hour (token_ttl: 3600) | BEARER cookie (HttpOnly); the Authorization: Bearer … header is also accepted |
| Refresh token | 30 days | REFRESH_TOKEN cookie (HttpOnly, limited to the /api/token path); refresh_tokens table |
The cookie takes priority; the header is for scripts and command-line tools. Over HTTPS, cookies are Secure and SameSite=Strict.
Routes
| Route | Role |
|---|---|
POST /api/login | Sign-in (email, password); returns the token, the refresh token and the profile, or requires2FA when two-factor authentication is enabled for the account |
POST /api/2fa/verify | Second sign-in step |
POST /api/token/refresh | New access token; the refresh token is replaced on every use |
POST /api/token/invalidate | Sign-out: deletes the refresh token and clears the cookies |
The frontend refreshes the token before each request when it is not in memory (page reload), expired or about to expire, and also after a 401 response.
CSRF protection
When a request is authenticated by cookie, every mutation (POST, PUT, PATCH, DELETE) must carry the X-CSRF-TOKEN header, equal to the XSRF-TOKEN cookie ("double submit cookie" pattern). Calls authenticated by the Authorization header do not need it. Can be disabled in development only (CSRF_ENABLED=false).
Keys
| Item | Value |
|---|---|
| Location | ~/pch-sig/backend/config/jwt/ on the server, mounted on /app/config/jwt in the backend container: read-only in production, writable in pre-production |
| Passphrase | JWT_PASSPHRASE in .env.prod |
| Creation | the backend entrypoint can generate a 4096-bit RSA pair when keys are missing or the passphrase does not open them, provided the directory is writable (pre-production, development); in production keys are created on the host |
The .env.prod passphrase and the host keys go together. To change one, change the other:
cd ~/pch-sig/backend/config/jwt
openssl genpkey -out private.pem -aes256 -algorithm rsa -pkeyopt rsa_keygen_bits:4096 -pass env:JWT_PASSPHRASE
openssl pkey -in private.pem -out public.pem -pubout -passin env:JWT_PASSPHRASE
sudo chown 82:82 private.pem public.pem # 82 = www-data in the php:8.3-fpm-alpine image
sudo chmod 600 private.pem && sudo chmod 644 public.pem
(JWT_PASSPHRASE exported in the shell for the duration of the command, then copied into .env.prod.) Then recreate the backend container (up -d --force-recreate --no-deps backend). Users are not signed out: the refresh token does not depend on the key, the first call returns 401, the frontend refreshes and replays the request. Full secrets rotation procedure: docs/ROTATION_SECRETS.md in the repository.
Get a token from the command line
curl -s -X POST https://sig-preprod.ucp-pch.org/api/login \
-H "Content-Type: application/json" \
-d '{"email":"<EMAIL>","password":"<PASSWORD>"}' > token.json
curl -s https://sig-preprod.ucp-pch.org/api/users/me/permissions \
-H "Authorization: Bearer $(jq -r .token token.json)"
Never paste a token or a password into a ticket, a log or a chat; delete token.json after use.
Troubleshooting
| Symptom | Cause | Action |
|---|---|---|
| 500 at sign-in, "Unable to create a signed JWT" | private key unreadable by www-data, or passphrase that does not match the keys | on the host, owner 82:82 and mode 600 for private.pem; otherwise check the passphrase |
| Every user signed out | refresh_tokens table emptied, or cookies cleared | sign in again |
| Endless 401 | server clock drift, or cookie blocked by the browser | docker exec pch_backend date; check that the site is served over HTTPS |
| 403 "CSRF" on a mutation | X-CSRF-TOKEN header missing or stale | reload the page (the XSRF-TOKEN cookie is reissued) |
docker exec -u www-data pch_backend php bin/console debug:config lexik_jwt_authentication