Security Best Practices
Review checklist
- [ ] All OAuth endpoints over HTTPS (except localhost)
- [ ] PKCE
code_challenge_method=S256(plainis rejected) - [ ] redirect_uri matches the registered value exactly (trailing slash, case, and query all make it a different URI)
- [ ]
stategenerated randomly, stored in the session, and verified at the callback - [ ] JWT verification includes
iss="logi"+aud=LOGI_CLIENT_ID+exp - [ ] Refresh token: HttpOnly Secure SameSite=Strict cookie on the web, OS secure storage on mobile
- [ ] client_secret kept in env/secret manager only (masked in Git/CI logs)
- [ ] Subscribed to the
token.revokedwebhook
1. PKCE S256
code_challenge_method=S2562. redirect_uri exact match
The following are all different URIs:
https://app.example.com/cb
https://app.example.com/cb/ ← trailing slash
https://APP.example.com/cb ← case
https://app.example.com/cb?foo=1 ← query3. state (CSRF defense)
ts
const state = base64url(crypto.getRandomValues(new Uint8Array(32)));
sessionStorage.setItem("oauth_state", state);
// callback
if (params.get("state") !== sessionStorage.getItem("oauth_state")) {
throw new Error("CSRF: state mismatch");
}4. Refresh Token storage
- ❌ Browser localStorage/IndexedDB (XSS-vulnerable)
- ✅ Web: HttpOnly Secure SameSite=Strict cookie
- ✅ Mobile: OS secure storage
| Platform | Recommended storage | Key option |
|---|---|---|
| iOS Native | Keychain Services | kSecAttrAccessibleWhenUnlockedThisDeviceOnly (blocks iCloud sync) |
| Android Native | DataStore + Tink or Ackee Guardian | setUserAuthenticationRequired(true) |
| Flutter | flutter_secure_storage 10.0+ | KeychainAccessibility.first_unlock_this_device |
| React Native | react-native-keychain 10.0+ | ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY |
Android EncryptedSharedPreferences is not recommended
Deprecated as of androidx.security:security-crypto 1.1.0. Use DataStore + Tink or Ackee Guardian.
iOS Keychain iCloud sync
Syncs by default. Tokens must explicitly set ...ThisDeviceOnly accessibility. iOS guide
5. JWT verification
ts
await jwtVerify(token, jwks, {
issuer: "logi",
audience: process.env.LOGI_CLIENT_ID,
// exp and nbf are verified automatically by jose
});
// Sensitive APIs: also check the jti revocation status
await assertNotRevoked(payload.jti);Skipping aud verification can cause a token from a different logi app to be accepted by mistake.
6. Client Secret
- Shown once on issue → copy it to your secret manager/env immediately
- Never commit it to Git; mask it in CI logs
- If leaked:
POST /developer/applications/:id/rotate_secret→ the old secret is invalidated immediately
7. HTTPS + HSTS
- HTTPS is required on all OAuth endpoints
- logi response:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload - redirect_uri registration is HTTPS-only (localhost excepted)
Attack scenario → defense mapping (OWASP)
| Attack | logi side | RP side |
|---|---|---|
| Code interception (OWASP A02) | PKCE S256 | verifier confined to sessionStorage |
| Code injection from another RP (A01) | code_challenge + client_secret binding | aud verification |
| CSRF on authorize (A01) | state verification | state stored in the session |
| Refresh token reuse (A07) | rotation + chain revoke | cookie storage + Set-Cookie overwrite |
| Replay on authorize (A07) | nonce (openid) | nonce verification |
| Open redirect (A01) | redirect_uri exact whitelist | strict registration |
| Brute force (A07) | 10 attempts / 15 min → 30-min lockout + Cloudflare | — |
| Suspicious login (A09) | country/device/burst detection → suspicious=true | check user.locked_until |
Token leak response matrix
| Leaked item | Automatic response | Operator action |
|---|---|---|
| Refresh token | Chain revoke on reuse detection | Tell the user to re-authenticate |
| Access token | Rejected when the jti is found revoked | Keep expiry short |
| Client secret | The old secret is invalidated immediately on rotate_secret | Rotate the CI/CD secret |
| PAK | Returns 401 from the next request after revocation | Issue a new PAK, then update the automation |
2FA (TOTP + backup codes)
- TOTP secret: Security settings → scan the QR (compatible with Google Authenticator/1Password/Authy)
- Login verification: 6-digit TOTP / single-use backup code / Passkey UV
otp_verified_atrecorded on the session → the basis for re-prompting on sensitive operations- Backup codes: shown once right after issue, single-use, kept somewhere other than your authenticator app
Passkey
Recommended over password + OTP (phishing-resistant, non-reusable, origin-bound). On the web, adopt it with navigator.credentials.create/get as a stage after the OAuth Flow.
Logging / notifications (handled automatically by logi)
- Every login →
login_logs(IP/UA/country) login_notification_enabledON → push/email (Phase 2)suspicious=true→ forced notification even if notifications are OFF (Phase 2)auto_lock_on_suspicious_loginON → auto-lock for 24 hours on suspicion- RPs sync by subscribing to webhooks like
user.unlinked
Troubleshooting
| Symptom | Cause | Action |
|---|---|---|
invalid_grant on token exchange | Missing/mismatched PKCE verifier | Check the code_verifier storage path |
invalid_request redirect_uri mismatch | trailing slash/case/query | Match the registered value exactly |
| JWT verify failure (audience) | aud not set or a different client_id | Check the LOGI_CLIENT_ID env |
| 401 immediately after a refresh | Token chain revoked (reuse detected) | Prompt a re-login |
| Login lockout | More than 10 attempts in 15 min | Wait 30 minutes or have an operator unlock |