Comprehensive security guidelines for OAuth 2.0, OIDC, and SAML implementations in the Okta SSO Hub.
- Security Overview
- OAuth 2.0 & OIDC Security
- SAML Security
- Token Management
- API Security
- Network Security
- Secrets Management
- Common Vulnerabilities
- Security Checklist
This project implements multiple security layers:
- Authentication - Verify user identity (password + MFA)
- Authorization - Control access to resources (scopes, claims)
- Transport Security - Encrypt data in transit (HTTPS/TLS)
- Token Security - Protect and validate tokens (JWT signature)
- Session Security - Secure session management (HttpOnly cookies)
- Input Validation - Prevent injection attacks (sanitize inputs)
- Monitoring - Detect and respond to threats (logging, alerts)
Complies with:
- ✅ OAuth 2.0 - RFC 6749
- ✅ OAuth 2.0 Security Best Practices - RFC 8252, RFC 8628
- ✅ OpenID Connect Core - OIDC Spec
- ✅ SAML 2.0 - OASIS Standard
- ✅ PKCE - RFC 7636 (Proof Key for Code Exchange)
- ✅ JWT - RFC 7519 (JSON Web Tokens)
- ✅ SCIM 2.0 - RFC 7644
- ✅ NIST Cybersecurity Framework
Why PKCE?
- Eliminates need for client secrets in SPAs
- Prevents authorization code interception attacks
- Binds code exchange to original requestor
Implementation:
// React OIDC SPA uses PKCE automatically
const oktaAuth = new OktaAuth({
issuer: 'https://dev-12345678.okta.com/oauth2/default',
clientId: '0oa...',
redirectUri: 'http://localhost:3000/login/callback',
pkce: true, // PKCE enabled by default in Okta SDK
scopes: ['openid', 'profile', 'email']
});PKCE Flow:
- Generate random
code_verifier(43-128 chars) - Create
code_challenge= BASE64URL(SHA256(code_verifier)) - Send
code_challenge+ method (S256) to authorization endpoint - Receive authorization code
- Send
code_verifier+ code to token endpoint - Okta validates challenge matches verifier
- Return tokens only if valid
Purpose: Prevent Cross-Site Request Forgery attacks
How it works:
// Okta SDK handles state automatically
// Manual implementation:
const state = generateRandomString(32);
sessionStorage.setItem('oauth_state', state);
// Authorization request
const authUrl = `${issuer}/v1/authorize?
client_id=${clientId}&
response_type=code&
scope=openid&
redirect_uri=${redirectUri}&
state=${state}`;
// Callback validation
const returnedState = new URLSearchParams(window.location.search).get('state');
if (returnedState !== sessionStorage.getItem('oauth_state')) {
throw new Error('State mismatch - possible CSRF attack');
}Purpose: Prevent replay attacks on ID tokens
// Okta SDK includes nonce automatically
const nonce = generateRandomString(32);
// Nonce sent in auth request
// Okta includes nonce claim in ID token
// SDK validates nonce matchesCritical Security Control:
✅ Do:
- Exact match redirect URIs in Okta app configuration
- Use HTTPS in production (never HTTP)
- Avoid wildcards in redirect URIs
- Validate redirect_uri parameter matches registered URIs
❌ Don't:
- Use
http://in production - Allow open redirects
- Use query parameters in redirect URIs
- Trust redirect_uri from user input
Example Configuration:
Registered Redirect URIs:
✅ https://app.example.com/callback
✅ https://app.example.com/implicit/callback
❌ https://app.example.com/*
❌ http://app.example.com/callback (production)
✅ http://localhost:3000/login/callback (development only)
Principle of Least Privilege:
Request only necessary scopes:
// Good - minimal scopes
scopes: ['openid', 'profile', 'email']
// Bad - excessive scopes
scopes: ['openid', 'profile', 'email', 'groups', 'address', 'phone']Custom Scopes for API:
// Request specific API access
scopes: ['openid', 'profile', 'read:data', 'write:data']
// API validates scopes
if (!jwt.claims.scp.includes('read:data')) {
return res.status(403).json({ error: 'Insufficient scope' });
}Critical: Always validate SAML assertion signatures
# Flask SAML - python3-saml handles validation
auth = init_saml_auth(request)
auth.process_response()
errors = auth.get_errors()
if errors:
raise SecurityException(f'SAML validation failed: {errors}')
# Verify signature
if not auth.is_authenticated():
raise SecurityException('SAML assertion signature invalid')Best Practices:
-
Verify IdP Certificate:
# settings.json "idp": { "x509cert": "MIIDpDCCAoygAwIBAgIGAY..." # From Okta metadata }
-
Validate Certificate Chain:
- Ensure certificate is issued by trusted CA
- Check certificate hasn't expired
- Verify certificate matches Okta's current cert
-
Certificate Rotation:
- Monitor Okta certificate expiration
- Download new metadata before expiration
- Update
settings.jsonwith new cert - Test before old cert expires
-
SP Certificate (Optional but Recommended):
# Generate SP signing certificate openssl req -new -x509 -days 3652 -nodes \ -out sp.crt -keyout sp.key # Add to settings.json "sp": { "x509cert": "...", "privateKey": "..." }
Validate ALL of these:
- Signature: Cryptographic verification
- Issuer: Must match expected Okta entity ID
- Audience: Must match SP entity ID
- NotBefore/NotOnOrAfter: Time window validity
- InResponseTo: Matches original request ID
- Destination: Matches ACS URL
- Subject: Contains authenticated user identifier
# python3-saml validates these automatically
# Manual validation example:
assertion = auth.get_last_assertion()
if assertion.get_issuer() != expected_idp_entity_id:
raise SecurityException('Invalid issuer')
if assertion.get_audience() != sp_entity_id:
raise SecurityException('Invalid audience')
# Check time validity
not_before = assertion.get_not_before()
not_on_or_after = assertion.get_not_on_or_after()
now = datetime.utcnow()
if now < not_before or now >= not_on_or_after:
raise SecurityException('Assertion expired')Store processed assertion IDs:
import redis
redis_client = redis.StrictRedis()
@app.route('/saml/acs', methods=['POST'])
def acs():
auth = init_saml_auth(request)
auth.process_response()
# Get assertion ID
response_id = auth.get_last_response_id()
# Check if already processed
if redis_client.get(f'saml_assertion:{response_id}'):
raise SecurityException('Assertion replay detected')
# Store assertion ID (expire after 5 minutes)
redis_client.setex(f'saml_assertion:{response_id}', 300, '1')
# Continue processing...Implement SLO to prevent session hijacking:
@app.route('/logout')
def logout():
auth = init_saml_auth(request)
# Generate SLO request
slo_url = auth.logout(
return_to='/',
name_id=session.get('samlNameId'),
session_index=session.get('samlSessionIndex')
)
# Clear local session
session.clear()
# Redirect to IdP for global logout
return redirect(slo_url)Security Comparison:
| Storage | Security | Persistence | XSS Risk | Recommendation |
|---|---|---|---|---|
| Memory | Highest | Lost on refresh | None | Production (high security) |
| sessionStorage | High | Lost on tab close | Low | Production (balanced) |
| localStorage | Medium | Persists | Medium | Development only |
| Cookies | High (if HttpOnly) | Configurable | Low (if HttpOnly) | Backend sessions |
Recommended for SPAs:
const oktaAuth = new OktaAuth({
// ...
tokenManager: {
storage: 'sessionStorage', // Good balance
// storage: 'memory', // Highest security (tokens lost on refresh)
autoRenew: true,
expireEarlySeconds: 300 // Renew 5 minutes before expiry
}
});Always validate JWT tokens:
// Node.js API - Server-side validation
const OktaJwtVerifier = require('@okta/jwt-verifier');
const oktaJwtVerifier = new OktaJwtVerifier({
issuer: 'https://dev-12345678.okta.com/oauth2/default',
clientId: '0oa...'
});
async function verifyToken(req, res, next) {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
const token = authHeader.split(' ')[1];
// Validates:
// 1. Signature (cryptographic verification)
// 2. Issuer (matches expected issuer)
// 3. Audience (matches client ID or api://default)
// 4. Expiration (not expired)
// 5. Not before (nbf claim)
// 6. Issued at (iat claim)
const jwt = await oktaJwtVerifier.verifyAccessToken(token, 'api://default');
req.user = jwt.claims;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
}Configure appropriate lifetimes:
| Token Type | Recommended Lifetime | Okta Default |
|---|---|---|
| ID Token | 1 hour | 1 hour |
| Access Token | 1 hour | 1 hour |
| Refresh Token (SPA) | 7-90 days | 90 days |
| Refresh Token (Mobile) | 90 days | 90 days |
Configure in Okta:
- Go to Security → API → Authorization Servers → default
- Click Access Policies tab
- Edit rule
- Set token lifetimes:
- Access token: 1 hour
- Refresh token: 90 days (SPAs)
Automatic refresh (recommended):
// Okta SDK handles automatically
const oktaAuth = new OktaAuth({
tokenManager: {
autoRenew: true,
expireEarlySeconds: 300
}
});
// SDK refreshes tokens 5 minutes before expiryManual refresh (if needed):
try {
const newToken = await oktaAuth.tokenManager.renew('accessToken');
console.log('Token refreshed:', newToken);
} catch (error) {
// Refresh failed - redirect to login
await oktaAuth.signInWithRedirect();
}Revoke tokens on logout:
async function logout() {
const accessToken = await oktaAuth.getAccessToken();
const refreshToken = await oktaAuth.tokenManager.get('refreshToken');
// Revoke tokens at Okta
if (refreshToken) {
await oktaAuth.revokeRefreshToken(refreshToken.refreshToken);
}
if (accessToken) {
await oktaAuth.revokeAccessToken(accessToken);
}
// Clear local storage
await oktaAuth.signOut();
}Always use HTTPS in production:
// Express.js - Force HTTPS
app.use((req, res, next) => {
if (process.env.NODE_ENV === 'production' && !req.secure) {
return res.redirect('https://' + req.headers.host + req.url);
}
next();
});Restrict to specific origins:
const cors = require('cors');
// Good - specific origin
app.use(cors({
origin: 'https://app.example.com',
credentials: true
}));
// Bad - wildcard (never use in production)
app.use(cors({ origin: '*' })); // ❌ INSECURE
// Better - multiple specific origins
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com'
];
app.use(cors({
origin: (origin, callback) => {
if (allowedOrigins.includes(origin) || !origin) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true
}));Add security headers to all responses:
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://*.okta.com"]
}
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
}
}));
// Additional headers
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
next();
});Prevent brute force attacks:
const rateLimit = require('express-rate-limit');
// General API rate limit
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: 'Too many requests, please try again later'
});
app.use('/api/', apiLimiter);
// Stricter limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // Only 5 attempts
message: 'Too many login attempts, please try again later'
});
app.use('/api/auth/', authLimiter);Validate and sanitize ALL inputs:
const { body, validationResult } = require('express-validator');
app.post('/api/users',
// Validation rules
body('email').isEmail().normalizeEmail(),
body('name').trim().isLength({ min: 1, max: 100 }),
body('age').optional().isInt({ min: 0, max: 150 }),
// Check validation results
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Process valid data
createUser(req.body);
}
);Configure in Okta:
- Go to Security → API → Trusted Origins
- Add production origins only
- Never use wildcards
✅ Good:
- https://app.example.com (CORS + Redirect)
- https://admin.example.com (CORS + Redirect)
❌ Bad:
- http://app.example.com (HTTP in production)
- https://*.example.com (wildcard)
- * (wildcard all)
Restrict access by IP:
- Go to Security → Networks
- Create network zone:
- Name: "Office Network"
- Gateway IPs:
203.0.113.0/24
- Apply to authentication policies
Use CDN/WAF in production:
- Cloudflare (free tier available)
- AWS CloudFront + WAF
- Azure Front Door
Never commit secrets to Git:
# .env (in .gitignore)
OKTA_DOMAIN=dev-12345678.okta.com
OKTA_API_TOKEN=00abc...
OKTA_CLIENT_ID=0oa...
DATABASE_URL=postgresql://...Load secrets:
// Node.js
require('dotenv').config();
const apiToken = process.env.OKTA_API_TOKEN;# Python
from dotenv import load_dotenv
load_dotenv()
api_token = os.getenv('OKTA_API_TOKEN')Use secret managers:
- AWS Secrets Manager
- Azure Key Vault
- Google Secret Manager
- HashiCorp Vault
// Example: AWS Secrets Manager
const AWS = require('aws-sdk');
const secretsManager = new AWS.SecretsManager();
async function getSecret(secretName) {
const data = await secretsManager.getSecretValue({ SecretId: secretName }).promise();
return JSON.parse(data.SecretString);
}
const oktaCreds = await getSecret('okta/credentials');Rotate Okta API tokens every 90 days:
- Create new token in Okta
- Update environment variables
- Deploy updated configuration
- Verify new token works
- Revoke old token
Prevention:
// React automatically escapes content
<div>{userInput}</div> // Safe
// Dangerous - never use
<div dangerouslySetInnerHTML={{__html: userInput}} /> // ❌
// Server-side - sanitize HTML
const sanitizeHtml = require('sanitize-html');
const clean = sanitizeHtml(userInput);Prevention:
// Good - parameterized query
db.query('SELECT * FROM users WHERE email = $1', [userEmail]);
// Bad - string concatenation
db.query(`SELECT * FROM users WHERE email = '${userEmail}'`); // ❌Prevention:
- Use
stateparameter in OAuth (automatic in Okta SDK) - SameSite cookies:
SameSite=StrictorLax - CSRF tokens for form submissions
// Express.js
const csrf = require('csurf');
app.use(csrf({ cookie: true }));Prevention:
# Flask - regenerate session ID after login
@app.route('/login', methods=['POST'])
def login():
# Authenticate user
user = authenticate(username, password)
# Regenerate session ID
session.clear()
session['user_id'] = user.id
session.modified = True- PKCE enabled for OAuth flows
- State parameter validated
- Redirect URIs exactly match registered URIs
- Secrets in
.envfile (not committed to Git) -
.gitignoreincludes.env,*.key,*.pem - SAML signature validation enabled
- JWT signature validation implemented
- Token expiration checked
- Input validation on all endpoints
- CORS restricted to specific origins
- Security headers configured
- Rate limiting implemented
- HTTPS/TLS enforced (no HTTP)
- Trusted origins configured in Okta
- Custom domain configured (optional)
- MFA enforced for all users
- Session timeout configured (max 8 hours)
- Secrets stored in secret manager
- API tokens rotated (90-day schedule)
- Security headers (CSP, HSTS, etc.)
- CDN/WAF enabled (DDoS protection)
- Monitoring and alerting configured
- Incident response plan documented
- Regular security audits scheduled
- Audit logging enabled
- User consent flows implemented (if required)
- Data retention policy configured
- Privacy policy published
- Terms of service accepted
- GDPR compliance (if applicable)
- SOC 2 Type II (Okta provides this)
Monitor for suspicious activity:
- Go to Reports → System Log
- Watch for:
- Multiple failed login attempts
- MFA challenges failed
- User lockouts
- API authentication failures
- Token revocations
- Go to Reports → System Log
- Create alert for:
Event: user.session.access_admin_app Condition: More than 5 failed attempts in 10 minutes Action: Email security team
Log security events:
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'security.log' })
]
});
// Log authentication events
logger.info('Login attempt', {
user: userEmail,
ip: req.ip,
success: true
});
// Log authorization failures
logger.warn('Unauthorized access attempt', {
user: userEmail,
resource: req.path,
ip: req.ip
});- Revoke all tokens for affected user
- Force password reset
- Review audit logs for unauthorized access
- Rotate API tokens if admin token compromised
- Notify affected users
- Generate new SP certificate
- Update metadata in Okta
- Revoke old certificate
- Investigate how compromise occurred
- Audit all SAML sessions
- OAuth 2.0 Security Best Practices
- OWASP Top 10
- Okta Security Best Practices
- NIST Digital Identity Guidelines
Security is Critical! Follow these practices to protect user data and prevent breaches.