Skip to main content

JWT Authentication

Bemba authenticates API calls with JWT tokens (LexikJWTAuthenticationBundle) and renews the session with refresh tokens stored in the database (Gesdinet).

Tokens​

TokenLifetimeWhere it travels
Access token (RSA-signed JWT)1 hour (token_ttl: 3600)BEARER cookie (HttpOnly); the Authorization: Bearer … header is also accepted
Refresh token30 daysREFRESH_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​

RouteRole
POST /api/loginSign-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/verifySecond sign-in step
POST /api/token/refreshNew access token; the refresh token is replaced on every use
POST /api/token/invalidateSign-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​

ItemValue
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
PassphraseJWT_PASSPHRASE in .env.prod
Creationthe 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
Key rotation

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​

SymptomCauseAction
500 at sign-in, "Unable to create a signed JWT"private key unreadable by www-data, or passphrase that does not match the keyson the host, owner 82:82 and mode 600 for private.pem; otherwise check the passphrase
Every user signed outrefresh_tokens table emptied, or cookies clearedsign in again
Endless 401server clock drift, or cookie blocked by the browserdocker exec pch_backend date; check that the site is served over HTTPS
403 "CSRF" on a mutationX-CSRF-TOKEN header missing or stalereload the page (the XSRF-TOKEN cookie is reissued)
docker exec -u www-data pch_backend php bin/console debug:config lexik_jwt_authentication

See also​