Security Best Practices
SSO Version: 5.24.0
Overview
This guide covers security best practices for integrating with the SSO service using OAuth 2.0. Following these guidelines will help protect your application and users from common security vulnerabilities.
1. Never Expose Client Secret
Risk Level: CRITICAL 🔴
// ❌ NEVER DO THIS - Exposing secret in frontend code
const CLIENT_SECRET = 'abc123secret'; // WRONG!
fetch('https://sso.doneisbetter.com/api/oauth/token', {
body: JSON.stringify({
client_id: 'myapp',
client_secret: CLIENT_SECRET // DANGER: Secret exposed in browser!
})
});
// ✅ CORRECT - Token exchange happens on backend only
// Frontend: Redirect to SSO authorization
window.location.href = `https://sso.doneisbetter.com/api/oauth/authorize?client_id=myapp&...`;
// Backend: Exchange code for tokens (secret stays server-side)
const tokenResponse = await fetch(
'https://sso.doneisbetter.com/api/oauth/token',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code: authorizationCode,
client_id: process.env.SSO_CLIENT_ID,
client_secret: process.env.SSO_CLIENT_SECRET // Safe: Server-side only
})
}
);WHY: If client_secret is exposed in frontend code, anyone can impersonate your application and request tokens on behalf of any user.
2. Always Use HTTPS
Risk Level: CRITICAL 🔴
- ✅ All OAuth 2.0 communications MUST use HTTPS
- ✅ Redirect URIs must be HTTPS (no HTTP allowed in production)
- ✅ Never transmit tokens over unencrypted connections
- ⚠️ HTTP is only acceptable in local development (e.g.,
http://localhost:3000)
WHY: Without TLS/SSL encryption, tokens and authorization codes can be intercepted by attackers (man-in-the-middle attacks).
3. Implement CSRF Protection with State Parameter
Risk Level: HIGH 🟠
// WHY: Prevent Cross-Site Request Forgery (CSRF) attacks
// Step 1: Generate random state before redirecting to SSO
const state = crypto.randomBytes(32).toString('hex');
sessionStorage.setItem('oauth_state', state);
const authUrl = new URL('https://sso.doneisbetter.com/api/oauth/authorize');
authUrl.searchParams.append('state', state);
// ... other parameters
window.location.href = authUrl.toString();
// Step 2: Validate state in callback
const receivedState = req.query.state;
const expectedState = sessionStorage.getItem('oauth_state');
if (receivedState !== expectedState) {
throw new Error('State mismatch - possible CSRF attack!');
}
// Step 3: Clear state after successful validation
sessionStorage.removeItem('oauth_state');WHY: The state parameter ensures the OAuth callback is responding to a request your application initiated, preventing CSRF attacks.
4. Secure Token Storage
Risk Level: CRITICAL 🔴
Backend: Use HTTP-Only Cookies (Recommended)
// ✅ SECURE - HTTP-only cookies prevent XSS attacks
res.cookie('access_token', accessToken, {
httpOnly: true, // Cannot be accessed by JavaScript
secure: true, // Only transmitted over HTTPS
sameSite: 'lax', // CSRF protection
maxAge: 3600000 // 1 hour expiry
});
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 2592000000 // 30 days expiry
});Frontend: Never Store Tokens in LocalStorage
// ❌ NEVER DO THIS - Vulnerable to XSS attacks
localStorage.setItem('access_token', token); // WRONG!
sessionStorage.setItem('access_token', token); // ALSO WRONG!
// ✅ CORRECT - Let backend manage tokens via cookies
// Frontend just makes authenticated requests:
fetch('/api/auth/session', {
credentials: 'include' // Sends HTTP-only cookies automatically
});WHY: Tokens stored in localStorage or sessionStorage can be stolen via XSS attacks. HTTP-only cookies are inaccessible to JavaScript.
5. Validate and Decode ID Tokens Properly
Risk Level: HIGH 🟠
// WHY: Ensure token integrity and extract user info safely
import jwt from 'jsonwebtoken';
// ✅ Option 1: Just decode (if you trust the SSO server)
const decoded = jwt.decode(idToken);
const { sub: userId, email, name, role, permissionStatus } = decoded;
// ✅ Option 2: Verify signature (more secure, recommended)
const publicKey = await fetchSSOPublicKey(); // From /.well-known/jwks.json
const decoded = jwt.verify(idToken, publicKey, {
algorithms: ['RS256'],
issuer: 'https://sso.doneisbetter.com',
audience: process.env.SSO_CLIENT_ID
});
// ⚠️ Always check token expiration
if (decoded.exp * 1000 < Date.now()) {
throw new Error('Token expired');
}WHY: Verifying the ID token signature ensures it hasn't been tampered with and actually comes from the SSO server.
6. Handle App Permission Status Securely
Risk Level: MEDIUM 🟡
// WHY: Enforce app-level permissions to prevent unauthorized access
const { permissionStatus, role } = decoded; // From ID token
// ✅ Always check permission status before granting access
if (permissionStatus !== 'approved') {
if (permissionStatus === 'pending') {
return res.redirect('/access-pending');
}
if (permissionStatus === 'revoked') {
return res.redirect('/access-denied');
}
// Unknown status - deny access
return res.status(403).json({ error: 'Access not approved' });
}
// ✅ Check role for admin-only features
if (role === 'admin') {
// Grant admin access
} else {
// Regular user access only
}WHY: A user may authenticate successfully but not have permission to access your application. Always verify permissionStatus.
7. Implement Token Refresh Before Expiry
Risk Level: MEDIUM 🟡
// WHY: Maintain session continuity without forcing re-login
// ✅ Refresh tokens proactively (e.g., 5 minutes before expiry)
const TOKEN_REFRESH_BUFFER = 5 * 60 * 1000; // 5 minutes
async function ensureValidToken() {
const decoded = jwt.decode(accessToken);
const expiryTime = decoded.exp * 1000;
const now = Date.now();
if (expiryTime - now < TOKEN_REFRESH_BUFFER) {
// Token expiring soon, refresh it
await refreshAccessToken();
}
}
async function refreshAccessToken() {
const response = await fetch('/api/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET
})
});
const { access_token, id_token } = await response.json();
// Update stored tokens
}WHY: Access tokens expire quickly (1 hour default). Proactive refresh prevents session interruptions.
8. Secure Redirect URI Configuration
Risk Level: HIGH 🟠
- ✅ Register exact redirect URIs with SSO admin (no wildcards)
- ✅ Use HTTPS for all redirect URIs in production
- ⚠️ Avoid open redirects (validate redirect_uri parameter)
- ⚠️ Never use dynamic redirect URIs from user input
// ❌ DANGEROUS - Open redirect vulnerability
const redirectUri = req.query.redirect; // User-controlled!
window.location.href = `https://sso.doneisbetter.com/api/oauth/authorize?redirect_uri=${redirectUri}`;
// ✅ SAFE - Use pre-registered, hardcoded redirect URI
const ALLOWED_REDIRECT_URI = 'https://myapp.com/api/auth/callback';
window.location.href = `https://sso.doneisbetter.com/api/oauth/authorize?redirect_uri=${ALLOWED_REDIRECT_URI}`;WHY: Attackers can trick users into authorizing malicious applications by manipulating redirect URIs.
9. Implement Rate Limiting
Risk Level: MEDIUM 🟡
The SSO service implements rate limiting on all endpoints. Your application should handle rate limit errors gracefully:
// WHY: Prevent abuse and handle rate limit responses
const response = await fetch('https://sso.doneisbetter.com/api/oauth/token', {
// ... request config
});
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After'); // seconds
console.error(`Rate limited. Retry after ${retryAfter} seconds.`);
// ✅ Implement exponential backoff
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
// Retry request
}Current Rate Limits:
- Public endpoints: 100 requests/minute per IP
- OAuth endpoints: 50 requests/minute per client_id
- Admin endpoints: 200 requests/minute per admin session
10. Logout Securely
Risk Level: MEDIUM 🟡
// WHY: Ensure complete session termination
// Step 1: Revoke tokens with SSO server
await fetch('https://sso.doneisbetter.com/api/oauth/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: accessToken,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET
})
});
// Step 2: Clear local session (cookies, etc.)
res.clearCookie('access_token');
res.clearCookie('refresh_token');
res.clearCookie('id_token');
// Step 3: Optionally redirect to SSO logout (for single logout)
window.location.href = 'https://sso.doneisbetter.com/api/public/logout';WHY: Revoking tokens at the SSO server ensures they can't be reused even if intercepted.
Summary Checklist
- ☑️ Never expose
client_secretin frontend code - ☑️ Always use HTTPS for OAuth endpoints
- ☑️ Implement CSRF protection with
stateparameter - ☑️ Store tokens in HTTP-only cookies (never localStorage)
- ☑️ Validate ID token signatures and expiration
- ☑️ Check
permissionStatusbefore granting access - ☑️ Implement token refresh before expiry
- ☑️ Use exact, pre-registered redirect URIs
- ☑️ Handle rate limiting with exponential backoff
- ☑️ Revoke tokens on logout