JavaScript and React course Β· Module 10: React Ecosystem and Future
Comprehensive Security Practices in React
In this lesson6
A single unchecked comment field is enough for a foreign signal to take over the helm: the attacker pastes HTML with a script, and every passenger's browser runs that script. React application security is several layers of shields, from escaping in JSX, through tokens and dependencies, to the OWASP Top 10 rules.
1. Cross-Site Scripting (XSS)
XSS means injecting a script that runs in the victim's browser. By default React escapes every value in JSX curly braces, turning special HTML characters into safe entities:
1// React automatically escapes these values
2function UserProfile({ userData }) {
3 return (
4 <div>
5 {/* Safe - React escapes content */}
6 <h1>{userData.name}</h1>
7 <p>{userData.description}</p>
8
9 {/* DANGEROUS - bypasses escaping */}
10 <div dangerouslySetInnerHTML={{ __html: userData.bio }} />
11 </div>
12 );
13}{userData.name} will be displayed as text, even if someone types a <script> tag there. The exception is dangerouslySetInnerHTML, which inserts raw HTML and bypasses this protection.
Sanitization with DOMPurify
When you have to show HTML from an untrusted source, for example content from a text editor, run it through DOMPurify first:
1// Safe components with validation
2function SafeHTMLRenderer({ content, allowedTags = [] }) {
3 // DOMPurify library for HTML sanitization
4 const cleanHTML = DOMPurify.sanitize(content, {
5 ALLOWED_TAGS: allowedTags,
6 ALLOWED_ATTR: ['href', 'title', 'alt', 'src'],
7 ALLOW_DATA_ATTR: false
8 });
9
10 return (
11 <div dangerouslySetInnerHTML={{ __html: cleanHTML }} />
12 );
13}
14
15// URL validation
16function SafeLink({ href, children, ...props }) {
17 const isValidURL = (url) => {
18 try {
19 const urlObj = new URL(url);
20 // Allow only safe protocols
21 return ['http:', 'https:', 'mailto:'].includes(urlObj.protocol);
22 } catch {
23 return false;
24 }
25 };
26
27 if (!isValidURL(href)) {
28 console.warn('Unsafe URL blocked:', href);
29 return <span>{children}</span>;
30 }
31
32 return (
33 <a
34 href={href}
35 {...props}
36 rel="noopener noreferrer" // Protection against tabnabbing
37 >
38 {children}
39 </a>
40 );
41}
42
43// Safe image rendering
44function SafeImage({ src, alt, ...props }) {
45 const [imageSrc, setImageSrc] = useState(null);
46 const [error, setError] = useState(false);
47
48 useEffect(() => {
49 // Validate image source
50 if (src && typeof src === 'string') {
51 try {
52 const url = new URL(src);
53 if (['http:', 'https:', 'data:'].includes(url.protocol)) {
54 setImageSrc(src);
55 } else {
56 setError(true);
57 }
58 } catch {
59 setError(true);
60 }
61 }
62 }, [src]);
63
64 if (error) {
65 return <div className="image-error">Invalid image source</div>;
66 }
67
68 return imageSrc ? (
69 <img
70 src={imageSrc}
71 alt={alt}
72 {...props}
73 onError={() => setError(true)}
74 />
75 ) : null;
76}The rule is: dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(rawHtml) }}, never raw HTML. You do not need to sanitize plain text from form fields, because React escapes it anyway. SafeLink lets through only the http:, https: and mailto: protocols, and SafeImage checks the image source.
Content Security Policy
The second shield works in the browser: the Content Security Policy header says where scripts, styles and images may be loaded from:
1// CSP Header configuration
2const cspConfig = {
3 'default-src': ["'self'"],
4 'script-src': [
5 "'self'",
6 "'unsafe-inline'", // Only in development
7 'https://apis.google.com',
8 'https://cdn.jsdelivr.net'
9 ],
10 'style-src': [
11 "'self'",
12 "'unsafe-inline'", // For CSS-in-JS
13 'https://fonts.googleapis.com'
14 ],
15 'img-src': [
16 "'self'",
17 'data:',
18 'https:',
19 'blob:'
20 ],
21 'font-src': [
22 "'self'",
23 'https://fonts.gstatic.com'
24 ],
25 'connect-src': [
26 "'self'",
27 'https://api.example.com',
28 'wss://socket.example.com'
29 ],
30 'frame-ancestors': ["'none'"],
31 'form-action': ["'self'"],
32 'upgrade-insecure-requests': []
33};
34
35// Next.js CSP configuration
36// next.config.js
37const securityHeaders = [
38 {
39 key: 'Content-Security-Policy',
40 value: Object.entries(cspConfig)
41 .map(([directive, sources]) =>
42 `${directive} ${sources.join(' ')}`
43 )
44 .join('; ')
45 },
46 {
47 key: 'X-Frame-Options',
48 value: 'DENY'
49 },
50 {
51 key: 'X-Content-Type-Options',
52 value: 'nosniff'
53 },
54 {
55 key: 'Referrer-Policy',
56 value: 'strict-origin-when-cross-origin'
57 },
58 {
59 key: 'Permissions-Policy',
60 value: 'camera=(), microphone=(), geolocation=()'
61 }
62];
63
64module.exports = {
65 async headers() {
66 return [
67 {
68 source: '/(.*)',
69 headers: securityHeaders
70 }
71 ];
72 }
73};cspConfig becomes a single header, and headers() in next.config.js adds it to every response. 'unsafe-inline' in script-src disables a large part of the protection, so keep it only in the development environment.
2. JWT Security
A JWT token is a boarding pass, so stealing it means taking over the account. SecureTokenManager shows two ways of storing it, and useAuth checks the token's validity every 5 minutes:
1// Secure JWT storage and handling
2class SecureTokenManager {
3 constructor() {
4 this.tokenKey = 'auth_token';
5 this.refreshTokenKey = 'refresh_token';
6 }
7
8 // Storage in httpOnly cookies (best approach)
9 setTokens(accessToken, refreshToken) {
10 // Send to backend to set httpOnly cookies
11 fetch('/api/auth/set-tokens', {
12 method: 'POST',
13 headers: { 'Content-Type': 'application/json' },
14 body: JSON.stringify({ accessToken, refreshToken })
15 });
16 }
17
18 // Alternatively - sessionStorage (less secure)
19 setTokensInStorage(accessToken, refreshToken) {
20 // NEVER use localStorage for tokens!
21 sessionStorage.setItem(this.tokenKey, accessToken);
22
23 if (refreshToken) {
24 // Refresh token in a secure location
25 this.storeRefreshTokenSecurely(refreshToken);
26 }
27 }
28
29 getToken() {
30 return sessionStorage.getItem(this.tokenKey);
31 }
32
33 removeTokens() {
34 sessionStorage.removeItem(this.tokenKey);
35 sessionStorage.removeItem(this.refreshTokenKey);
36
37 // Also logout from cookies
38 fetch('/api/auth/logout', { method: 'POST' });
39 }
40
41 // Token validation
42 isTokenValid(token) {
43 if (!token) return false;
44
45 try {
46 // JWT encodes the payload in base64url, while atob() expects plain base64
47 const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
48 const payload = JSON.parse(atob(base64));
49 const now = Date.now() / 1000;
50
51 // Check if token has expired
52 return payload.exp > now;
53 } catch {
54 return false;
55 }
56 }
57
58 // Automatic token refresh
59 async refreshTokenIfNeeded() {
60 const token = this.getToken();
61
62 if (!token || !this.isTokenValid(token)) {
63 return await this.refreshToken();
64 }
65
66 return token;
67 }
68
69 async refreshToken() {
70 try {
71 const response = await fetch('/api/auth/refresh', {
72 method: 'POST',
73 credentials: 'include' // For httpOnly cookies
74 });
75
76 if (response.ok) {
77 const { accessToken } = await response.json();
78 sessionStorage.setItem(this.tokenKey, accessToken);
79 return accessToken;
80 }
81 } catch (error) {
82 console.error('Token refresh failed:', error);
83 this.removeTokens();
84 }
85
86 return null;
87 }
88}
89
90// Hook for authorization
91function useAuth() {
92 const [user, setUser] = useState(null);
93 const [loading, setLoading] = useState(true);
94 const tokenManager = useRef(new SecureTokenManager());
95
96 const login = async (credentials) => {
97 try {
98 const response = await fetch('/api/auth/login', {
99 method: 'POST',
100 headers: { 'Content-Type': 'application/json' },
101 body: JSON.stringify(credentials)
102 });
103
104 if (response.ok) {
105 const { user, accessToken, refreshToken } = await response.json();
106
107 // Secure token storage
108 tokenManager.current.setTokens(accessToken, refreshToken);
109 setUser(user);
110
111 return { success: true };
112 }
113 } catch (error) {
114 return { success: false, error: error.message };
115 }
116 };
117
118 const logout = () => {
119 tokenManager.current.removeTokens();
120 setUser(null);
121 };
122
123 // Automatic token refresh
124 useEffect(() => {
125 const interval = setInterval(async () => {
126 await tokenManager.current.refreshTokenIfNeeded();
127 }, 5 * 60 * 1000); // Every 5 minutes
128
129 return () => clearInterval(interval);
130 }, []);
131
132 return { user, login, logout, loading };
133}The safest option is an httpOnly cookie, which JavaScript cannot read, ideally set by the server right at login. sessionStorage disappears when the tab is closed, but it protects against XSS as poorly as localStorage, because any script from that domain can read both. isTokenValid checks only the expiration date, and only the server verifies the signature.
Axios Interceptors
Interceptors add headers to every request and retry it after refreshing the token:
1// Secure Axios configuration
2const createSecureAxiosInstance = () => {
3 const tokenManager = new SecureTokenManager();
4
5 const axiosInstance = axios.create({
6 baseURL: process.env.REACT_APP_API_URL,
7 timeout: 10000,
8 withCredentials: true // For httpOnly cookies
9 });
10
11 // Request interceptor
12 axiosInstance.interceptors.request.use(
13 async (config) => {
14 const token = await tokenManager.refreshTokenIfNeeded();
15
16 if (token) {
17 config.headers.Authorization = `Bearer ${token}`;
18 }
19
20 // Add CSRF token if available
21 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
22 if (csrfToken) {
23 config.headers['X-CSRF-Token'] = csrfToken;
24 }
25
26 return config;
27 },
28 (error) => Promise.reject(error)
29 );
30
31 // Response interceptor
32 axiosInstance.interceptors.response.use(
33 (response) => response,
34 async (error) => {
35 const originalRequest = error.config;
36
37 if (error.response?.status === 401 && !originalRequest._retry) {
38 originalRequest._retry = true;
39
40 const newToken = await tokenManager.refreshToken();
41
42 if (newToken) {
43 originalRequest.headers.Authorization = `Bearer ${newToken}`;
44 return axiosInstance(originalRequest);
45 } else {
46 // Redirect to login
47 window.location.href = '/login';
48 }
49 }
50
51 return Promise.reject(error);
52 }
53 );
54
55 return axiosInstance;
56};On a 401 status the interceptor refreshes the token and repeats the request only once, which the _retry flag guards. The X-CSRF-Token header defends against CSRF, an attack in which a foreign site sends a request with the victim's cookies. Such a token must come from a cryptographically secure generator, like crypto.getRandomValues(), never from Math.random(), and the server compares it with the value stored in the session.
3. Environment Variables
The .env.example file shows the team which variables the project needs, without real values:
1# .env.example - a template for the team, without real values
2# Secrets such as API_SECRET_KEY or DATABASE_URL never go into this file
3# or into REACT_APP_ variables, because the browser will see those
4REACT_APP_API_URL=https://api.example.com
5REACT_APP_ENVIRONMENT=development
6REACT_APP_SENTRY_DSN=your_sentry_dsn_hereEverything with the REACT_APP_ prefix ends up in the JavaScript bundle, so every user can read it, and secrets stay on the server. The prefix comes from Create React App, which the React team deprecated in 2025. In Vite the equivalent is VITE_, in Next.js NEXT_PUBLIC_, and the rule stays the same.
EnvironmentValidator checks at startup whether the required variables exist and aborts the launch when something is missing:
1// Environment variable validation
2class EnvironmentValidator {
3 static requiredVars = [
4 'REACT_APP_API_URL',
5 'REACT_APP_ENVIRONMENT'
6 ];
7
8 static validate() {
9 const missing = this.requiredVars.filter(
10 varName => !process.env[varName]
11 );
12
13 if (missing.length > 0) {
14 throw new Error(
15 `Missing required environment variables: ${missing.join(', ')}`
16 );
17 }
18 }
19
20 static get(key, defaultValue = null) {
21 const value = process.env[key];
22
23 if (!value && defaultValue === null) {
24 console.warn(`Environment variable ${key} is not set`);
25 }
26
27 return value || defaultValue;
28 }
29
30 static getSecure(key) {
31 const value = process.env[key];
32
33 if (!value) {
34 throw new Error(`Secure environment variable ${key} is required`);
35 }
36
37 return value;
38 }
39}
40
41// Application configuration
42const appConfig = {
43 apiUrl: EnvironmentValidator.getSecure('REACT_APP_API_URL'),
44 environment: EnvironmentValidator.get('REACT_APP_ENVIRONMENT', 'development'),
45 isDevelopment: process.env.NODE_ENV === 'development',
46 isProduction: process.env.NODE_ENV === 'production',
47
48 // Helper functions
49 get isSecure() {
50 return this.apiUrl.startsWith('https://');
51 },
52
53 get shouldEnableDevTools() {
54 return this.isDevelopment && !this.isProduction;
55 }
56};
57
58// Validation at application startup
59EnvironmentValidator.validate();An error at startup is better than an application that silently sends requests to undefined. getSecure throws an exception, while get only warns and returns a default value.
ConfigProvider splits settings by environment: debugging, log level and mocks:
1// ConfigProvider.jsx
2const ConfigContext = createContext();
3
4export function ConfigProvider({ children }) {
5 const config = useMemo(() => {
6 const baseConfig = {
7 apiUrl: process.env.REACT_APP_API_URL,
8 environment: process.env.REACT_APP_ENVIRONMENT,
9 version: process.env.REACT_APP_VERSION || '1.0.0'
10 };
11
12 // Environment-specific configuration
13 const environmentConfigs = {
14 development: {
15 debug: true,
16 logLevel: 'debug',
17 enableMocks: true
18 },
19 staging: {
20 debug: true,
21 logLevel: 'info',
22 enableMocks: false
23 },
24 production: {
25 debug: false,
26 logLevel: 'error',
27 enableMocks: false
28 }
29 };
30
31 return {
32 ...baseConfig,
33 ...environmentConfigs[baseConfig.environment] || environmentConfigs.production
34 };
35 }, []);
36
37 return (
38 <ConfigContext.Provider value={config}>
39 {children}
40 </ConfigContext.Provider>
41 );
42}
43
44export const useConfig = () => {
45 const context = useContext(ConfigContext);
46 if (!context) {
47 throw new Error('useConfig must be used within ConfigProvider');
48 }
49 return context;
50};An unknown environment gets the production configuration, which is the safest one: no debugging and no mocks.
4. Dependency Scanning
The weakest link is often someone else's code. The scripts in package.json run a vulnerability audit and a license check:
1// package.json scripts for security
2{
3 "scripts": {
4 "audit": "npm audit",
5 "audit:fix": "npm audit fix",
6 "audit:force": "npm audit fix --force",
7 "security:check": "npm audit && npm run license:check",
8 "license:check": "license-checker --summary",
9 "deps:update": "npm-check-updates -u",
10 "deps:check": "npm outdated"
11 },
12 "devDependencies": {
13 "license-checker": "^25.0.1",
14 "npm-check-updates": "^23.1.0",
15 "audit-ci": "^7.1.0"
16 }
17}npm audit compares your dependencies against a database of known vulnerabilities. Be careful with npm audit fix --force, because it can bump major versions and break the application.
It is worth running the same checks automatically on every push and pull request:
1# .github/workflows/security.yml
2name: Security Checks
3
4on: [push, pull_request]
5
6jobs:
7 security:
8 runs-on: ubuntu-latest
9 permissions:
10 contents: read
11 security-events: write # CodeQL uploads results to the Security tab
12
13 steps:
14 - uses: actions/checkout@v7
15
16 - name: Initialize CodeQL
17 uses: github/codeql-action/init@v4
18 with:
19 languages: javascript-typescript
20
21 - name: Setup Node.js
22 uses: actions/setup-node@v7
23 with:
24 node-version: 'lts/*'
25 cache: 'npm'
26
27 - name: Install dependencies
28 run: npm ci
29
30 - name: Run npm audit
31 run: npm audit --audit-level high
32
33 - name: Check for known vulnerabilities
34 run: npx audit-ci --high
35
36 - name: License compliance check
37 run: npm run license:check
38
39 - name: SAST with CodeQL
40 uses: github/codeql-action/analyze@v4
41
42 - name: Dependency Review
43 uses: actions/dependency-review-action@v5
44 if: github.event_name == 'pull_request'The pipeline will stop on a high-level vulnerability, and dependency-review-action will check new dependencies in the pull request. CodeQL works in two steps: init prepares the analysis, and analyze scans the code and uploads the results, for which the job needs the security-events: write permission. node-version: 'lts/*' picks the current LTS version of Node.js, and Dependabot can bump the action versions (package-ecosystem: github-actions).
SecurityMonitor listens in the browser for securitypolicyviolation events, meaning CSP violations, and reports them to the server:
1// SecurityMonitor.jsx
2class SecurityMonitor {
3 constructor() {
4 this.violations = [];
5 this.init();
6 }
7
8 init() {
9 // Monitor CSP violations
10 document.addEventListener('securitypolicyviolation', (e) => {
11 this.reportViolation({
12 type: 'CSP_VIOLATION',
13 directive: e.violatedDirective,
14 blockedURI: e.blockedURI,
15 documentURI: e.documentURI,
16 timestamp: new Date().toISOString()
17 });
18 });
19
20 // Monitor console.error for potential security issues
21 const originalError = console.error;
22 console.error = (...args) => {
23 const message = args.join(' ');
24
25 if (this.isSecurityRelated(message)) {
26 this.reportViolation({
27 type: 'SECURITY_ERROR',
28 message,
29 timestamp: new Date().toISOString()
30 });
31 }
32
33 originalError.apply(console, args);
34 };
35 }
36
37 isSecurityRelated(message) {
38 const securityKeywords = [
39 'unsafe-inline',
40 'unsafe-eval',
41 'xss',
42 'csrf',
43 'cors',
44 'unauthorized',
45 'forbidden'
46 ];
47
48 return securityKeywords.some(keyword =>
49 message.toLowerCase().includes(keyword)
50 );
51 }
52
53 reportViolation(violation) {
54 this.violations.push(violation);
55
56 // Send to monitoring system
57 if (process.env.NODE_ENV === 'production') {
58 fetch('/api/security/violations', {
59 method: 'POST',
60 headers: { 'Content-Type': 'application/json' },
61 body: JSON.stringify(violation)
62 }).catch(error => {
63 console.error('Failed to report security violation:', error);
64 });
65 }
66 }
67
68 getViolations() {
69 return [...this.violations];
70 }
71}
72
73// Hook for security monitoring
74export function useSecurityMonitor() {
75 const monitor = useRef(new SecurityMonitor());
76
77 const reportCustomViolation = useCallback((violation) => {
78 monitor.current.reportViolation({
79 type: 'CUSTOM_VIOLATION',
80 ...violation,
81 timestamp: new Date().toISOString()
82 });
83 }, []);
84
85 return {
86 reportViolation: reportCustomViolation,
87 getViolations: () => monitor.current.getViolations()
88 };
89}You will learn about a blocked script before a user reports it. Overriding console.error is a global intervention, though, so use it carefully.
5. Data Validation and Sanitization
InputValidator checks required fields, length and patterns, and SecureForm validates fields as they are typed and cleans the values before sending:
1// InputValidator.js
2class InputValidator {
3 static patterns = {
4 email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
5 phone: /^\+?[\d\s\-\(\)]+$/,
6 url: /^https?:\/\/.+/,
7 alphanumeric: /^[a-zA-Z0-9]+$/,
8 noSpecialChars: /^[a-zA-Z0-9\s\-_]+$/
9 };
10
11 static validate(value, rules) {
12 const errors = [];
13
14 // Required validation
15 if (rules.required && (!value || value.trim() === '')) {
16 errors.push('This field is required');
17 }
18
19 if (!value) return errors;
20
21 // Length validation
22 if (rules.minLength && value.length < rules.minLength) {
23 errors.push(`Minimum length is ${rules.minLength} characters`);
24 }
25
26 if (rules.maxLength && value.length > rules.maxLength) {
27 errors.push(`Maximum length is ${rules.maxLength} characters`);
28 }
29
30 // Pattern validation
31 if (rules.pattern && !this.patterns[rules.pattern]?.test(value)) {
32 errors.push(`Invalid ${rules.pattern} format`);
33 }
34
35 // Custom validation
36 if (rules.custom) {
37 const customError = rules.custom(value);
38 if (customError) errors.push(customError);
39 }
40
41 // XSS prevention
42 if (rules.preventXSS && this.containsPotentialXSS(value)) {
43 errors.push('Input contains potentially dangerous content');
44 }
45
46 return errors;
47 }
48
49 static containsPotentialXSS(value) {
50 const xssPatterns = [
51 /<script[^>]*>.*?<\/script>/gi,
52 /javascript:/gi,
53 /on\w+\s*=/gi,
54 /<iframe[^>]*>/gi,
55 /<object[^>]*>/gi,
56 /<embed[^>]*>/gi,
57 /eval\s*\(/gi,
58 /expression\s*\(/gi
59 ];
60
61 return xssPatterns.some(pattern => pattern.test(value));
62 }
63
64 static sanitize(value, options = {}) {
65 if (!value) return value;
66
67 let sanitized = value;
68
69 // Remove HTML tags
70 if (options.stripHTML) {
71 sanitized = sanitized.replace(/<[^>]*>/g, '');
72 }
73
74 // Escape HTML entities
75 if (options.escapeHTML) {
76 sanitized = sanitized
77 .replace(/&/g, '&')
78 .replace(/</g, '<')
79 .replace(/>/g, '>')
80 .replace(/"/g, '"')
81 .replace(/'/g, ''');
82 }
83
84 // Remove null bytes
85 sanitized = sanitized.replace(/\0/g, '');
86
87 // Trim whitespace
88 if (options.trim !== false) {
89 sanitized = sanitized.trim();
90 }
91
92 return sanitized;
93 }
94}
95
96// SecureForm component
97function SecureForm({ onSubmit, children }) {
98 const [formData, setFormData] = useState({});
99 const [errors, setErrors] = useState({});
100 const [isSubmitting, setIsSubmitting] = useState(false);
101
102 const validateField = useCallback((name, value, rules) => {
103 const fieldErrors = InputValidator.validate(value, rules);
104
105 setErrors(prev => ({
106 ...prev,
107 [name]: fieldErrors.length > 0 ? fieldErrors : undefined
108 }));
109
110 return fieldErrors.length === 0;
111 }, []);
112
113 const handleInputChange = useCallback((name, value, rules) => {
114 // Keep the raw text: React escapes it when rendering
115 setFormData(prev => ({
116 ...prev,
117 [name]: value
118 }));
119
120 // Validate on change
121 validateField(name, value, rules);
122 }, [validateField]);
123
124 const handleSubmit = async (e) => {
125 e.preventDefault();
126 setIsSubmitting(true);
127
128 // Validate all fields with the rules stored in the children
129 let isValid = true;
130 const newErrors = {};
131
132 React.Children.forEach(children, child => {
133 if (!React.isValidElement(child) || !child.props.name) return;
134
135 const fieldName = child.props.name;
136 const fieldErrors = InputValidator.validate(formData[fieldName], child.props.validationRules || {});
137
138 if (fieldErrors.length > 0) {
139 newErrors[fieldName] = fieldErrors;
140 isValid = false;
141 }
142 });
143
144 setErrors(newErrors);
145
146 if (isValid) {
147 // Trim values and remove null bytes only before sending
148 const cleanData = Object.fromEntries(
149 Object.entries(formData).map(([key, value]) => [key, InputValidator.sanitize(value)])
150 );
151
152 try {
153 await onSubmit(cleanData);
154 } catch (error) {
155 console.error('Form submission failed:', error);
156 }
157 }
158
159 setIsSubmitting(false);
160 };
161
162 return (
163 <form onSubmit={handleSubmit}>
164 {React.Children.map(children, child => {
165 if (React.isValidElement(child) && child.props.name) {
166 return React.cloneElement(child, {
167 value: formData[child.props.name] || '',
168 onChange: (e) => handleInputChange(
169 child.props.name,
170 e.target.value,
171 child.props.validationRules
172 ),
173 error: errors[child.props.name],
174 disabled: isSubmitting
175 });
176 }
177 return child;
178 })}
179 </form>
180 );
181}SecureForm keeps the raw text in state, because React escapes it when rendering, and escaping entities on every keystroke would corrupt the typed text: & would turn into &, and after the next character into &amp;. sanitize trims the values only before sending, and escapeHTML is useful only when the text goes into HTML outside React. Validation in the browser improves convenience but does not secure anything, because an attacker will send the request bypassing the form. The server must check the data again, and treat the expressions from containsPotentialXSS as a signal, not a shield.
6. OWASP Top 10
Finally, a checklist based on the OWASP Top 10:2025, the most popular ranking of web application risks. The code shows selected categories in ranking order:
1// OWASP Top 10:2025 checklist (selected categories)
2
3// A01:2025 Broken Access Control - the UI hides views, but the server checks permissions
4function ProtectedRoute({ children, requiredRole, requiredPermissions }) {
5 const { user, hasRole, hasPermissions } = useAuth();
6
7 if (!user) {
8 return <Navigate to="/login" replace />;
9 }
10
11 if (requiredRole && !hasRole(requiredRole)) {
12 return <div>Access Denied: Insufficient role</div>;
13 }
14
15 if (requiredPermissions && !hasPermissions(requiredPermissions)) {
16 return <div>Access Denied: Insufficient permissions</div>;
17 }
18
19 return children;
20}
21
22// A02:2025 Security Misconfiguration - headers, CORS and the XML parser (XXE)
23const SecurityConfig = {
24 securityHeaders: {
25 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
26 'X-Content-Type-Options': 'nosniff',
27 'X-Frame-Options': 'DENY',
28 'X-XSS-Protection': '0',
29 'Referrer-Policy': 'strict-origin-when-cross-origin'
30 },
31
32 corsConfig: {
33 origin: process.env.ALLOWED_ORIGINS?.split(',') || [],
34 credentials: true,
35 optionsSuccessStatus: 200
36 },
37
38 // The browser's DOMParser does not fetch external XML entities
39 parseXMLSafely: (xmlString) => {
40 const doc = new DOMParser().parseFromString(xmlString, 'text/xml');
41
42 if (doc.querySelector('parsererror')) {
43 throw new Error('Invalid XML format');
44 }
45
46 return doc;
47 }
48};
49
50// A03:2025 Software Supply Chain Failures - npm ci, npm audit and dependency review from section 4
51
52// A04:2025 Cryptographic Failures - randomness from Web Crypto, data encryption on the server
53const CryptoPractices = {
54 // Token from a cryptographically secure generator, never from Math.random()
55 randomToken: (bytes = 32) => {
56 const values = crypto.getRandomValues(new Uint8Array(bytes));
57 return Array.from(values, (b) => b.toString(16).padStart(2, '0')).join('');
58 }
59};
60
61// A05:2025 Injection - data kept apart from the query, never concatenated into SQL text
62const InjectionPrevention = {
63 // Always use prepared statements (backend code)
64 safeQuery: (query, params) => {
65 return db.prepare(query).run(params);
66 },
67
68 // Parameter validation
69 validateParams: (params, schema) => {
70 return schema.validate(params);
71 }
72};
73
74// A07:2025 Authentication Failures - password rules according to NIST SP 800-63B
75const AuthenticationSecurity = {
76 passwordPolicy: {
77 minLength: 15, // 8 if the account has MFA
78 maxLength: 64, // allow long passphrases
79 checkBreachedPasswords: true, // reject breached and most common passwords
80 requireCharacterMix: false, // no forced uppercase letters or special characters
81 forcePeriodicChange: false // change the password only after a suspected breach
82 },
83
84 // Limit failed login attempts
85 loginAttempts: {
86 maxAttempts: 5,
87 lockoutDuration: 15 * 60 * 1000 // 15 minutes
88 },
89
90 // Session in a cookie
91 sessionConfig: {
92 httpOnly: true,
93 secure: true,
94 sameSite: 'strict',
95 maxAge: 30 * 60 * 1000 // 30 minutes
96 }
97};The full OWASP Top 10:2025 list is A01 Broken Access Control, A02 Security Misconfiguration, A03 Software Supply Chain Failures, A04 Cryptographic Failures, A05 Injection, A06 Insecure Design, A07 Authentication Failures, A08 Software or Data Integrity Failures, A09 Security Logging and Alerting Failures and A10 Mishandling of Exceptional Conditions. XSS from the first section belongs to A05, and SecurityMonitor from the dependencies section is an example of A09. We set X-XSS-Protection to 0, because the outdated browser filter could create new vulnerabilities, and MDN recommends CSP instead. An encryption key in frontend code is not a secret, so encrypting sensitive data belongs on the server, and the browser takes random values from the Web Crypto API. The password rules follow NIST SP 800-63B (revision 4 from 2025), which OWASP refers to: length and rejecting breached passwords are what counts, and NIST forbids forcing special characters and periodic password changes.
Security covers all layers, from the client to the server, and requires regular audits and up-to-date dependencies. I recommend starting with three steps: DOMPurify for every piece of raw HTML, httpOnly cookies and an automatic audit in CI. They will all return in the Security Operations Center exercise.
Remember: each layer is a separate shield of the ship - React escapes, CSP filters, and the server checks everything once more.
Code for this lesson: App.jsx
1import React, { useState, useRef } from 'react';
2
3// Demonstration of security practices in React
4// XSS protection, HTML sanitization and input validation
5
6function XSSDemo() {
7 const [userInput, setUserInput] = useState('');
8 const [messages, setMessages] = useState([
9 { id: 1, text: 'Welcome aboard the station!', author: 'Captain' },
10 { id: 2, text: 'Defense systems active.', author: 'Officer' },
11 ]);
12 const nextId = useRef(3);
13
14 // React automatically escapes JSX content - protection against XSS
15 const maliciousInput = '<script>alert("hack!")<\/script><img src=x onerror=alert(1)>';
16
17 const addMessage = () => {
18 if (userInput.trim()) {
19 setMessages(prev => [...prev, {
20 id: nextId.current++,
21 text: userInput, // React safely escapes this data
22 author: 'User',
23 }]);
24 setUserInput('');
25 }
26 };
27
28 return (
29 <div style={styles.panel}>
30 <h2 style={styles.heading}>1. XSS Protection</h2>
31
32 <div style={{ marginBottom: '16px' }}>
33 <h4 style={styles.subheading}>Malicious input (automatically escaped):</h4>
34 <div style={{ background: '#0d1b2a', padding: '12px', borderRadius: '8px', color: '#ff6b6b', fontFamily: 'monospace', fontSize: '13px', wordBreak: 'break-all' }}>
35 {maliciousInput}
36 </div>
37 <p style={{ color: '#00ff88', fontSize: '13px' }}>
38 React renders this as text, not as HTML - the script will not run!
39 </p>
40 </div>
41
42 <div>
43 <h4 style={styles.subheading}>Safe space station chat:</h4>
44 <div style={{ background: '#0d1b2a', padding: '12px', borderRadius: '8px', maxHeight: '150px', overflowY: 'auto', marginBottom: '8px' }}>
45 {messages.map(msg => (
46 <div key={msg.id} style={{ marginBottom: '8px' }}>
47 <span style={{ color: '#00d4ff', fontWeight: 'bold' }}>{msg.author}: </span>
48 <span style={{ color: '#e0e1dd' }}>{msg.text}</span>
49 </div>
50 ))}
51 </div>
52 <div style={{ display: 'flex', gap: '8px' }}>
53 <input
54 value={userInput}
55 onChange={e => setUserInput(e.target.value)}
56 onKeyDown={e => e.key === 'Enter' && addMessage()}
57 placeholder="Type a message (try HTML/script)..."
58 aria-label="Type a message (try HTML/script)..."
59 style={{ flex: 1, minWidth: 0, padding: '8px 12px', borderRadius: '8px', border: '1px solid #415a77', background: '#0d1b2a', color: '#e0e1dd' }}
60 />
61 <button onClick={addMessage} style={{ padding: '8px 16px', borderRadius: '8px', border: 'none', background: '#00d4ff', color: '#0d1b2a', fontWeight: 'bold', cursor: 'pointer' }}>
62 Send
63 </button>
64 </div>
65 </div>
66 </div>
67 );
68}
69
70function DangerousHTMLDemo() {
71 const [showComparison, setShowComparison] = useState(false);
72
73 const safeHTML = '<strong>Important message</strong> from the <em>space</em> station';
74 const unsafeHTML = '<img src=x onerror="alert(1)"><b>Dangerous HTML</b>';
75
76 // Simplified filter: keeps a few tags without any attributes (including onerror, onclick)
77 // and removes the rest. For demonstration only - in an app use DOMPurify.sanitize()
78 const sanitize = (html) => {
79 const allowedTags = ['b', 'i', 'em', 'strong', 'p', 'br', 'span'];
80 return html.replace(/<(\/?)([a-zA-Z]+)[^>]*>/g, (match, slash, tag) =>
81 allowedTags.includes(tag.toLowerCase()) ? `<${slash}${tag.toLowerCase()}>` : ''
82 );
83 };
84
85 return (
86 <div style={{ ...styles.panel, marginTop: '16px' }}>
87 <h2 style={styles.heading}>2. dangerouslySetInnerHTML</h2>
88
89 <div style={{ display: 'grid', gap: '12px' }}>
90 <div style={styles.box}>
91 <h4 style={{ color: '#ff6b6b', margin: '0 0 8px' }}>Dangerous usage:</h4>
92 <code style={{ color: '#778da9', fontSize: '13px' }}>
93 {'dangerouslySetInnerHTML={{ __html: userInput }}'}
94 </code>
95 <p style={{ color: '#ff6b6b', fontSize: '13px', margin: '8px 0 0' }}>
96 Never use it with untrusted data!
97 </p>
98 </div>
99
100 <div style={styles.box}>
101 <h4 style={{ color: '#00ff88', margin: '0 0 8px' }}>Safe - after sanitization:</h4>
102 <div style={{ color: '#e0e1dd', marginBottom: '8px' }} dangerouslySetInnerHTML={{ __html: sanitize(safeHTML) }} />
103 <code style={{ color: '#778da9', fontSize: '12px', display: 'block' }}>
104 {'dangerouslySetInnerHTML={{ __html: sanitize(html) }}'}
105 </code>
106 <p style={{ color: '#ffd166', fontSize: '12px', margin: '8px 0 0' }}>
107 The sanitize function in this example is a simplification. In a real app use DOMPurify.sanitize(), which knows the pitfalls of the HTML parser.
108 </p>
109 </div>
110 </div>
111
112 <button
113 onClick={() => setShowComparison(visible => !visible)}
114 style={{ marginTop: '12px', padding: '8px 16px', borderRadius: '8px', border: '1px solid #415a77', background: 'transparent', color: '#778da9', cursor: 'pointer' }}
115 >
116 {showComparison ? 'Hide the sanitization comparison' : 'Show the sanitization comparison'}
117 </button>
118
119 {showComparison && (
120 <div style={{ ...styles.box, marginTop: '12px' }}>
121 <p style={{ color: '#778da9', margin: '0 0 4px', fontSize: '13px', wordBreak: 'break-all' }}>Before: {unsafeHTML}</p>
122 <p style={{ color: '#00ff88', margin: 0, fontSize: '13px', wordBreak: 'break-all' }}>After sanitization: {sanitize(unsafeHTML)}</p>
123 </div>
124 )}
125 </div>
126 );
127}
128
129// Password strength according to NIST SP 800-63B: length and rejecting common passwords matter, not forced special characters
130const COMMON_PASSWORDS = ['123456', 'password', 'qwerty', 'letmein', 'admin123'];
131
132function passwordStrength(password) {
133 if (COMMON_PASSWORDS.includes(password.toLowerCase())) return 0;
134 if (password.length >= 20) return 4;
135 if (password.length >= 15) return 3;
136 if (password.length >= 12) return 2;
137 if (password.length >= 8) return 1;
138 return 0;
139}
140
141function InputValidationDemo() {
142 const [email, setEmail] = useState('');
143 const [emailValid, setEmailValid] = useState(null);
144 const [password, setPassword] = useState('');
145
146 const validateEmail = (value) => {
147 setEmail(value);
148 if (!value) { setEmailValid(null); return; }
149 const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
150 setEmailValid(regex.test(value));
151 };
152
153 const strength = passwordStrength(password);
154 const isCommon = COMMON_PASSWORDS.includes(password.toLowerCase());
155 const strengthLabels = ['Very weak', 'Weak', 'Fair', 'Good', 'Strong'];
156 const strengthColors = ['#ff6b6b', '#ff6b6b', '#ffaa00', '#00d4ff', '#00ff88'];
157
158 return (
159 <div style={{ ...styles.panel, marginTop: '16px' }}>
160 <h2 style={styles.heading}>3. Input Validation</h2>
161
162 <div style={{ marginBottom: '16px' }}>
163 <label htmlFor="crew-email" style={styles.label}>Crew email:</label>
164 <input
165 id="crew-email"
166 value={email}
167 onChange={e => validateEmail(e.target.value)}
168 placeholder="pilot@station.space"
169 style={{ ...styles.input, borderColor: emailValid === null ? '#415a77' : emailValid ? '#00ff88' : '#ff6b6b' }}
170 />
171 {emailValid !== null && (
172 <p style={{ color: emailValid ? '#00ff88' : '#ff6b6b', fontSize: '13px', margin: '4px 0 0' }}>
173 {emailValid ? 'Valid email format' : 'Invalid email format'}
174 </p>
175 )}
176 </div>
177
178 <div>
179 <label htmlFor="crew-password" style={styles.label}>Access password:</label>
180 <input
181 id="crew-password"
182 type="password"
183 value={password}
184 onChange={e => setPassword(e.target.value)}
185 placeholder="At least 15 characters, ideally a few words"
186 style={styles.input}
187 />
188 {password && (
189 <div style={{ marginTop: '8px' }}>
190 <div style={{ display: 'flex', gap: '4px', marginBottom: '4px' }}>
191 {[1, 2, 3, 4].map(level => (
192 <div key={level} style={{ flex: 1, height: '4px', borderRadius: '2px', background: level <= strength ? strengthColors[strength] : '#415a77' }} />
193 ))}
194 </div>
195 <p style={{ color: strengthColors[strength], fontSize: '13px', margin: 0 }}>
196 {isCommon ? 'This password is on the list of the most common ones - choose another' : `Password strength: ${strengthLabels[strength]} (length: ${password.length})`}
197 </p>
198 </div>
199 )}
200 <p style={{ color: '#778da9', fontSize: '12px', margin: '8px 0 0' }}>A long phrase of a few words is stronger than a short password with special characters.</p>
201 </div>
202 </div>
203 );
204}
205
206const styles = {
207 panel: { background: '#1b2838', padding: '20px', borderRadius: '12px', border: '1px solid #415a77' },
208 heading: { color: '#00d4ff', marginTop: 0 },
209 subheading: { color: '#778da9' },
210 box: { background: '#0d1b2a', padding: '12px', borderRadius: '8px' },
211 label: { color: '#778da9', display: 'block', marginBottom: '4px' },
212 input: { width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #415a77', background: '#0d1b2a', color: '#e0e1dd', boxSizing: 'border-box' },
213};
214
215export default function App() {
216 return (
217 <div style={{ background: '#0d1b2a', minHeight: '100vh', padding: '24px 16px', color: '#e0e1dd', fontFamily: 'monospace' }}>
218 <div style={{ maxWidth: '650px', margin: '0 auto' }}>
219 <h1 style={{ color: '#00d4ff', textAlign: 'center' }}>Security in React</h1>
220 <p style={{ color: '#778da9', textAlign: 'center', marginBottom: '24px' }}>
221 Space station defense systems - security practices
222 </p>
223 <XSSDemo />
224 <DangerousHTMLDemo />
225 <InputValidationDemo />
226 </div>
227 </div>
228 );
229}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. How does React protect against XSS (Cross-Site Scripting) attacks by default?
2. When in React do you NEED to sanitize user input using a library like DOMPurify?
Hands-on tasks in the game
- Horizontal ordering
Arrange the syntax for using DOMPurify to safely render HTML in React:
- Code editor
Secure the panel that shows transmissions from unknown ships. React escapes the message text by itself, because it goes into JSX inside braces. ___BLANK1___: isSafeUrl returns true only for the http:, https: and mailto: protocols (false for javascript:, data: and any other). ___BLANK2___: fill the bytes array with random bytes from the cryptographically secure crypto.getRandomValues (never Math.random), so that a 32-character CSRF token is created. ___BLANK3___: a link that opens in a new tab gets rel with the values noopener and noreferrer. In the preview the link from the unknown ship must be blocked and its text shown as plain text.