Skip to content

Security Best Practices

Review checklist

  • [ ] All OAuth endpoints over HTTPS (except localhost)
  • [ ] PKCE code_challenge_method=S256 (plain is rejected)
  • [ ] redirect_uri matches the registered value exactly (trailing slash, case, and query all make it a different URI)
  • [ ] state generated 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.revoked webhook

1. PKCE S256

code_challenge_method=S256

PKCE details

2. 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   ← query

3. 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
PlatformRecommended storageKey option
iOS NativeKeychain ServiceskSecAttrAccessibleWhenUnlockedThisDeviceOnly (blocks iCloud sync)
Android NativeDataStore + Tink or Ackee GuardiansetUserAuthenticationRequired(true)
Flutterflutter_secure_storage 10.0+KeychainAccessibility.first_unlock_this_device
React Nativereact-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)

Attacklogi sideRP side
Code interception (OWASP A02)PKCE S256verifier confined to sessionStorage
Code injection from another RP (A01)code_challenge + client_secret bindingaud verification
CSRF on authorize (A01)state verificationstate stored in the session
Refresh token reuse (A07)rotation + chain revokecookie storage + Set-Cookie overwrite
Replay on authorize (A07)nonce (openid)nonce verification
Open redirect (A01)redirect_uri exact whiteliststrict registration
Brute force (A07)10 attempts / 15 min → 30-min lockout + Cloudflare
Suspicious login (A09)country/device/burst detection → suspicious=truecheck user.locked_until

Token leak response matrix

Leaked itemAutomatic responseOperator action
Refresh tokenChain revoke on reuse detectionTell the user to re-authenticate
Access tokenRejected when the jti is found revokedKeep expiry short
Client secretThe old secret is invalidated immediately on rotate_secretRotate the CI/CD secret
PAKReturns 401 from the next request after revocationIssue 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_at recorded 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_enabled ON → push/email (Phase 2)
  • suspicious=true → forced notification even if notifications are OFF (Phase 2)
  • auto_lock_on_suspicious_login ON → auto-lock for 24 hours on suspicion
  • RPs sync by subscribing to webhooks like user.unlinked

Troubleshooting

SymptomCauseAction
invalid_grant on token exchangeMissing/mismatched PKCE verifierCheck the code_verifier storage path
invalid_request redirect_uri mismatchtrailing slash/case/queryMatch the registered value exactly
JWT verify failure (audience)aud not set or a different client_idCheck the LOGI_CLIENT_ID env
401 immediately after a refreshToken chain revoked (reuse detected)Prompt a re-login
Login lockoutMore than 10 attempts in 15 minWait 30 minutes or have an operator unlock

Identity가 제품의 신뢰를 만듭니다.