Skip to main content

Security Best Practices

Comprehensive security guide for implementing and maintaining Signia authentication in your applications.

Overview

Security is a shared responsibility between Signia (the platform) and you (the application developer). This guide covers best practices for secure implementation.

Authentication Security

1. Always Use HTTPS

Why: Prevents man-in-the-middle attacks and token interception

Implementation:

// ✅ Production
redirectUri: 'https://myapp.com/oidc-callback'

// ❌ Never in production
redirectUri: 'http://myapp.com/oidc-callback'

// ✅ Local development exception
redirectUri: 'http://localhost:5173/oidc-callback'

Enforce HTTPS:

// Express middleware
app.use((req, res, next) => {
if (process.env.NODE_ENV === 'production' && !req.secure) {
return res.redirect(`https://${req.headers.host}${req.url}`);
}
next();
});

2. Protect Client Secrets

Never:

  • ❌ Commit secrets to version control
  • ❌ Expose secrets in frontend code
  • ❌ Log secrets in application logs
  • ❌ Share secrets in support tickets

Always:

  • ✅ Store in environment variables
  • ✅ Use secret management systems (AWS Secrets Manager, HashiCorp Vault)
  • ✅ Rotate secrets regularly (every 90 days)
  • ✅ Use different secrets per environment

Example:

# .env (not committed to git)
OIDC_CLIENT_SECRET=sk_live_abc123...

# .gitignore
.env
.env.local
.env.production
// Server-side (Node.js/Express) - Load from environment
const oidcClient = new OIDCClient({
clientId: process.env.OIDC_CLIENT_ID!,
clientSecret: process.env.OIDC_CLIENT_SECRET!, // Server-side only
redirectUri: process.env.OIDC_REDIRECT_URI!,
issuer: process.env.OIDC_ISSUER!
});

3. Implement PKCE

What: Proof Key for Code Exchange

Why: Protects against authorization code interception

When: Always use for public clients (SPAs, mobile apps)

const oidcClient = new OIDCClient({
clientId: 'YOUR_CLIENT_ID',
redirectUri: 'http://localhost:5173/oidc-callback',
issuer: 'https://tenant.signiaauth.com',
usePKCE: true // ✅ Enable PKCE
});

How it works:

  1. Client generates random code_verifier
  2. Creates code_challenge = SHA256(code_verifier)
  3. Sends code_challenge in authorization request
  4. Server stores code_challenge
  5. Client sends code_verifier during token exchange
  6. Server verifies SHA256(code_verifier) == stored challenge

4. Validate State Parameter

What: Random string to prevent CSRF attacks

Why: Ensures callback came from your authorization request

// Signia SDK handles this automatically
// But if implementing manually:

// Before redirect
const state = crypto.randomBytes(32).toString('hex');
sessionStorage.setItem('oauth_state', state);

// On callback
const returnedState = new URL(window.location.href).searchParams.get('state');
const expectedState = sessionStorage.getItem('oauth_state');

if (returnedState !== expectedState) {
throw new Error('Invalid state parameter - possible CSRF attack');
}

5. Validate Redirect URIs

Backend validation:

const ALLOWED_REDIRECT_URIS = [
'http://localhost:5173/oidc-callback',
'https://myapp.com/oidc-callback',
'https://staging.myapp.com/oidc-callback'
];

function validateRedirectUri(uri: string): boolean {
return ALLOWED_REDIRECT_URIS.some(allowed =>
uri.startsWith(allowed)
);
}

Dashboard configuration: Only add redirect URIs you control. Never add third-party domains.

Token Security

1. Secure Token Storage

Frontend (Browser):

// ✅ Best: HTTP-only cookies (backend-managed)
// Tokens not accessible to JavaScript
// Protected from XSS

// ⚠️ Acceptable: Memory only (loses on refresh)
// No persistence, good for security
// Bad for UX

// ❌ Never: localStorage or sessionStorage
// Vulnerable to XSS attacks

Recommended approach:

// Store tokens in memory only
class TokenManager {
private accessToken: string | null = null;
private refreshToken: string | null = null;

setTokens(access: string, refresh: string) {
this.accessToken = access;
this.refreshToken = refresh;
}

getAccessToken(): string | null {
return this.accessToken;
}

clear() {
this.accessToken = null;
this.refreshToken = null;
}
}

Backend (Node.js):

// ✅ Use secure session stores
import session from 'express-session';
import RedisStore from 'connect-redis';

app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // Not accessible to JavaScript
sameSite: 'lax', // CSRF protection
maxAge: 3600000 // 1 hour
}
}));

2. Token Validation

Always validate tokens:

async function validateAccessToken(token: string): Promise<boolean> {
try {
// Verify signature and claims
const decoded = await oidcClient.verifyAccessToken(token);

// Check expiration
if (decoded.exp < Date.now() / 1000) {
return false;
}

// Check issuer
if (decoded.iss !== 'https://tenant.signiaauth.com') {
return false;
}

// Check audience
if (decoded.aud !== 'YOUR_CLIENT_ID') {
return false;
}

return true;
} catch (error) {
return false;
}
}

3. Token Refresh

Automatic refresh before expiration:

async function refreshTokenIfNeeded(tokens: Tokens): Promise<Tokens> {
const decoded = jwt.decode(tokens.access_token);
const expiresIn = decoded.exp - Math.floor(Date.now() / 1000);

// Refresh if less than 5 minutes remaining
if (expiresIn < 300) {
const newTokens = await oidcClient.refreshAccessToken(tokens.refresh_token);
return newTokens;
}

return tokens;
}

4. Token Scope Validation

Validate required scopes:

function requireScope(requiredScope: string) {
return (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
const decoded = jwt.decode(token);

if (!decoded.scope.includes(requiredScope)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}

next();
};
}

// Usage
app.get('/api/admin', requireScope('admin'), (req, res) => {
// Admin-only endpoint
});

Session Security

1. Session Configuration

// Express session configuration
app.use(session({
secret: process.env.SESSION_SECRET!, // Strong random secret
name: '__session', // Don't use default 'connect.sid'
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS in production
httpOnly: true, // Prevent XSS
sameSite: 'lax', // CSRF protection
maxAge: 24 * 60 * 60 * 1000, // 24 hours
domain: process.env.COOKIE_DOMAIN // Set appropriate domain
},
store: new RedisStore({ // Use persistent store
client: redisClient,
prefix: 'sess:'
})
}));

2. Session Timeout

Implement idle timeout:

function sessionTimeout(timeout: number) {
return (req, res, next) => {
if (req.session.lastActivity) {
const elapsed = Date.now() - req.session.lastActivity;

if (elapsed > timeout) {
req.session.destroy(() => {
return res.redirect('/login?reason=timeout');
});
return;
}
}

req.session.lastActivity = Date.now();
next();
};
}

// 30 minute idle timeout
app.use(sessionTimeout(30 * 60 * 1000));

3. Concurrent Session Management

Limit concurrent sessions:

interface UserSession {
userId: string;
sessionId: string;
createdAt: number;
}

async function checkConcurrentSessions(userId: string, maxSessions: number) {
const sessions = await redis.smembers(`user:${userId}:sessions`);

if (sessions.length >= maxSessions) {
// Remove oldest session
const oldest = sessions[0];
await redis.srem(`user:${userId}:sessions`, oldest);
await redis.del(`session:${oldest}`);
}
}

API Security

1. Rate Limiting

Prevent brute force attacks:

import rateLimit from 'express-rate-limit';

const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
message: 'Too many login attempts, please try again later',
standardHeaders: true,
legacyHeaders: false
});

app.post('/api/login', loginLimiter, loginHandler);

2. CORS Configuration

Restrict cross-origin requests:

import cors from 'cors';

app.use(cors({
origin: [
'https://myapp.com',
'https://staging.myapp.com',
process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : ''
].filter(Boolean),
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));

3. Input Validation

Validate all inputs:

import { z } from 'zod';

const loginSchema = z.object({
email: z.string().email(),
redirectUri: z.string().url()
});

app.post('/api/login', async (req, res) => {
try {
const validated = loginSchema.parse(req.body);
// Process login
} catch (error) {
return res.status(400).json({ error: 'Invalid input' });
}
});

4. SQL Injection Prevention

Use parameterized queries:

// ✅ Safe - parameterized
const user = await db.query(
'SELECT * FROM users WHERE email = $1',
[email]
);

// ❌ Vulnerable - string concatenation
const user = await db.query(
`SELECT * FROM users WHERE email = '${email}'`
);

Frontend Security

1. XSS Prevention

Sanitize user input:

import DOMPurify from 'dompurify';

function SafeHTML({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

Use framework protections:

// ✅ React automatically escapes
<div>{userInput}</div>

// ❌ Dangerous
<div dangerouslySetInnerHTML={{ __html: userInput }} />

2. Content Security Policy

Implement CSP headers:

app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline' https://signiaauth.com; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"connect-src 'self' https://signiaauth.com https://api.myapp.com; " +
"frame-ancestors 'none';"
);
next();
});

3. Secure Dependencies

Regularly audit dependencies:

# Check for vulnerabilities
npm audit

# Fix automatically
npm audit fix

# Use dependency scanning (GitHub, Snyk, etc.)

Monitoring & Logging

1. Security Logging

Log security events:

import winston from 'winston';

const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'security.log' })
]
});

// Log authentication events
logger.info('User login', {
userId: user.id,
ip: req.ip,
userAgent: req.get('user-agent'),
timestamp: new Date()
});

// Log suspicious activity
logger.warn('Failed login attempt', {
email: req.body.email,
ip: req.ip,
reason: 'Invalid credentials'
});

2. Anomaly Detection

Detect suspicious patterns:

async function detectAnomalies(userId: string, ip: string) {
const recentLogins = await getRecentLogins(userId, 24); // Last 24 hours

// Check for multiple IPs
const uniqueIPs = new Set(recentLogins.map(l => l.ip));
if (uniqueIPs.size > 5) {
await alertSecurity('Multiple IP addresses detected', { userId, count: uniqueIPs.size });
}

// Check for unusual location
const location = await getIPLocation(ip);
const userLocations = await getUserLocations(userId);
if (!userLocations.includes(location.country)) {
await alertSecurity('Login from new country', { userId, country: location.country });
}
}

Compliance

1. GDPR Compliance

Data minimization:

// Only request necessary scopes
scopes: ['openid', 'email'] // ✅ Minimal

// Avoid
scopes: ['openid', 'profile', 'email', 'address', 'phone'] // ❌ Excessive

Right to be forgotten:

async function deleteUserData(userId: string) {
// Delete user account
await db.users.delete({ id: userId });

// Delete sessions
await redis.del(`user:${userId}:*`);

// Delete audit logs (anonymize or delete per policy)
await db.auditLogs.update(
{ userId },
{ userId: 'deleted_user' }
);
}

2. PCI DSS (if applicable)

  • Never store credit card data
  • Use tokenization for payments
  • Implement network segmentation
  • Regular security audits

Security Checklist

Pre-Launch

  • HTTPS enabled in production
  • Client secrets in environment variables
  • PKCE enabled for public clients
  • State parameter validation implemented
  • Redirect URI validation configured
  • Rate limiting enabled
  • CORS properly configured
  • Session security configured
  • Token storage secure
  • Input validation implemented
  • Dependencies audited
  • CSP headers configured
  • Security logging enabled

Post-Launch

  • Monitor security logs daily
  • Rotate secrets every 90 days
  • Update dependencies monthly
  • Conduct security audits quarterly
  • Review access controls quarterly
  • Test incident response plan annually

Incident Response

1. Compromise Response

If client secret compromised:

  1. Immediately regenerate secret in dashboard
  2. Update application configuration
  3. Review audit logs for suspicious activity
  4. Notify affected users if necessary

If user account compromised:

  1. Suspend account immediately
  2. Revoke all sessions
  3. Reset authentication credentials
  4. Investigate breach
  5. Notify user

2. Security Contacts

Report vulnerabilities:

  • Email: security@signiaid.com
  • Include: Detailed description, steps to reproduce
  • Response time: Within 24 hours

Next Steps