diff --git a/web/package.json b/web/package.json
index ed3e7b9b..3b75dbe1 100644
--- a/web/package.json
+++ b/web/package.json
@@ -12,11 +12,15 @@
     "@material-ui/styles": "4.11.4",
     "axios": "0.25.0",
     "classnames": "2.3.1",
+    "i18next": "21.6.0",
+    "i18next-browser-languagedetector": "6.1.2",
+    "i18next-http-backend": "1.3.1",
     "qrcode.react": "1.0.1",
     "query-string": "7.1.0",
     "react": "17.0.2",
     "react-dom": "17.0.2",
     "react-ga": "3.3.0",
+    "react-i18next": "11.14.3",
     "react-loading": "2.0.3",
     "react-otp-input": "2.4.0",
     "react-router-dom": "6.2.1",
@@ -77,6 +81,9 @@
       "^@hooks/(.*)$": [
         "<rootDir>/src/hooks/$1"
       ],
+      "^@i18n/(.*)$": [
+        "<rootDir>/src/i18n/$1"
+      ],
       "^@layouts/(.*)$": [
         "<rootDir>/src/layouts/$1"
       ],
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 5a1efd58..0346160b 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -33,6 +33,9 @@ specifiers:
   eslint-plugin-react: 7.28.0
   eslint-plugin-react-hooks: 4.3.0
   husky: 7.0.4
+  i18next: 21.6.0
+  i18next-browser-languagedetector: 6.1.2
+  i18next-http-backend: 1.3.1
   jest: 27.4.7
   jest-transform-stub: 2.0.0
   jest-watch-typeahead: 1.0.0
@@ -42,6 +45,7 @@ specifiers:
   react: 17.0.2
   react-dom: 17.0.2
   react-ga: 3.3.0
+  react-i18next: 11.14.3
   react-loading: 2.0.3
   react-otp-input: 2.4.0
   react-router-dom: 6.2.1
@@ -64,11 +68,15 @@ dependencies:
   '@material-ui/styles': 4.11.4_b3482aaf5744fc7c2aeb7941b0e0a78f
   axios: 0.25.0
   classnames: 2.3.1
+  i18next: 21.6.0
+  i18next-browser-languagedetector: 6.1.2
+  i18next-http-backend: 1.3.1
   qrcode.react: 1.0.1_react@17.0.2
   query-string: 7.1.0
   react: 17.0.2
   react-dom: 17.0.2_react@17.0.2
   react-ga: 3.3.0_react@17.0.2
+  react-i18next: 11.14.3_i18next@21.6.0+react@17.0.2
   react-loading: 2.0.3_react@17.0.2
   react-otp-input: 2.4.0_react-dom@17.0.2+react@17.0.2
   react-router-dom: 6.2.1_react-dom@17.0.2+react@17.0.2
@@ -3677,6 +3685,12 @@ packages:
     resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
     dev: true
 
+  /cross-fetch/3.1.4:
+    resolution: {integrity: sha512-1eAtFWdIubi6T4XPy6ei9iUFoKpUkIF971QLN8lIvvvwueI65+Nw5haMNKUwfJxabqlIIDODJKGrQ66gxC0PbQ==}
+    dependencies:
+      node-fetch: 2.6.1
+    dev: false
+
   /cross-spawn/6.0.5:
     resolution: {integrity: sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==}
     engines: {node: '>=4.8'}
@@ -5004,6 +5018,12 @@ packages:
     resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
     dev: true
 
+  /html-parse-stringify/3.0.1:
+    resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
+    dependencies:
+      void-elements: 3.1.0
+    dev: false
+
   /http-proxy-agent/4.0.1:
     resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==}
     engines: {node: '>= 6'}
@@ -5040,6 +5060,24 @@ packages:
     resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==}
     dev: false
 
+  /i18next-browser-languagedetector/6.1.2:
+    resolution: {integrity: sha512-YDzIGHhMRvr7M+c8B3EQUKyiMBhfqox4o1qkFvt4QXuu5V2cxf74+NCr+VEkUuU0y+RwcupA238eeolW1Yn80g==}
+    dependencies:
+      '@babel/runtime': 7.16.3
+    dev: false
+
+  /i18next-http-backend/1.3.1:
+    resolution: {integrity: sha512-o79n4GBBRpl20hByC+ne/S1UaSZ4iGAn59Hu2TEZGjN0WLB72L7WrM39Cshziyrssp6MQfdI8wjToU2Q6kpSvA==}
+    dependencies:
+      cross-fetch: 3.1.4
+    dev: false
+
+  /i18next/21.6.0:
+    resolution: {integrity: sha512-RjNuACL35wWZgtkyMcjcCmK7R72u3P6jTNbGKzrvHGI9M0iK5Vn1DsBIwOByppaXLIbe0viJ79Nz2h8w1UwPoQ==}
+    dependencies:
+      '@babel/runtime': 7.16.3
+    dev: false
+
   /iconv-lite/0.4.24:
     resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
     engines: {node: '>=0.10.0'}
@@ -6474,6 +6512,11 @@ packages:
     resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==}
     dev: true
 
+  /node-fetch/2.6.1:
+    resolution: {integrity: sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==}
+    engines: {node: 4.x || >=6.0.0}
+    dev: false
+
   /node-int64/0.4.0:
     resolution: {integrity: sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=}
     dev: true
@@ -6951,6 +6994,18 @@ packages:
       react: 17.0.2
     dev: false
 
+  /react-i18next/11.14.3_i18next@21.6.0+react@17.0.2:
+    resolution: {integrity: sha512-Hf2aanbKgYxPjG8ZdKr+PBz9sY6sxXuZWizxCYyJD2YzvJ0W9JTQcddVEjDaKyBoCyd3+5HTerdhc9ehFugc6g==}
+    peerDependencies:
+      i18next: '>= 19.0.0'
+      react: '>= 16.8.0'
+    dependencies:
+      '@babel/runtime': 7.16.3
+      html-parse-stringify: 3.0.1
+      i18next: 21.6.0
+      react: 17.0.2
+    dev: false
+
   /react-is/16.13.1:
     resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
 
@@ -8102,6 +8157,11 @@ packages:
       fsevents: 2.3.2
     dev: true
 
+  /void-elements/3.1.0:
+    resolution: {integrity: sha1-YU9/v42AHwu18GYfWy9XhXUOTwk=}
+    engines: {node: '>=0.10.0'}
+    dev: false
+
   /w3c-hr-time/1.0.2:
     resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==}
     dependencies:
diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts
new file mode 100644
index 00000000..eea65964
--- /dev/null
+++ b/web/src/i18n/index.ts
@@ -0,0 +1,35 @@
+import i18n from "i18next";
+import LanguageDetector from "i18next-browser-languagedetector";
+import XHR from "i18next-http-backend";
+import { initReactI18next } from "react-i18next";
+
+import langEn from "@i18n/locales/en.json";
+import langEs from "@i18n/locales/es.json";
+
+const resources = {
+    en: langEn,
+    es: langEs,
+};
+
+const options = {
+    order: ["querystring", "navigator"],
+    lookupQuerystring: "lng",
+};
+
+i18n.use(XHR)
+    .use(LanguageDetector)
+    .use(initReactI18next)
+    .init({
+        detection: options,
+        resources,
+        ns: [""],
+        defaultNS: "",
+        fallbackLng: "en",
+        supportedLngs: ["en", "es"],
+        interpolation: {
+            escapeValue: false,
+        },
+        debug: false,
+    });
+
+export default i18n;
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
new file mode 100644
index 00000000..8750382d
--- /dev/null
+++ b/web/src/i18n/locales/en.json
@@ -0,0 +1,53 @@
+{
+    "Portal": {
+        "An email has been sent to your address to complete the process": "An email has been sent to your address to complete the process.",
+        "Authenticated": "Authenticated",
+        "Cancel": "Cancel",
+        "Contact your administrator to register a device": "Contact your administrator to register a device.",
+        "Could not obtain user settings": "Could not obtain user settings",
+        "Done": "Done",
+        "Enter new password": "Enter new password",
+        "Enter one-time password": "Enter one-time password",
+        "Failed to register device, the provided link is expired or has already been used": "Failed to register device, the provided link is expired or has already been used",
+        "Hi": "Hi",
+        "Incorrect username or password": "Incorrect username or password.",
+        "Loading": "Loading",
+        "Logout": "Logout",
+        "Lost your device?": "Lost your device?",
+        "Methods": "Methods",
+        "Need Google Authenticator?": "Need Google Authenticator?",
+        "New password": "New password",
+        "No verification token provided": "No verification token provided",
+        "OTP Secret copied to clipboard": "OTP Secret copied to clipboard.",
+        "OTP URL copied to clipboard": "OTP URL copied to clipboard.",
+        "One-Time Password": "One-Time Password",
+        "Password has been reset": "Password has been reset.",
+        "Password": "Password",
+        "Passwords do not match": "Passwords do not match.",
+        "Push Notification": "Push Notification",
+        "Register device": "Register device",
+        "Register your first device by clicking on the link below": "Register your first device by clicking on the link below.",
+        "Remember me": "Remember me",
+        "Repeat new password": "Repeat new password",
+        "Reset password": "Reset password",
+        "Reset password?": "Reset password?",
+        "Reset": "Reset",
+        "Scan QR Code": "Scan QR Code",
+        "Secret": "Secret",
+        "Security Key - U2F": "Security Key - U2F",
+        "Select a Device": "Select a Device",
+        "Sign in": "Sign in",
+        "Sign out": "Sign out",
+        "The resource you're attempting to access requires two-factor authentication": "The resource you're attempting to access requires two-factor authentication.",
+        "There was a problem initiating the registration process": "There was a problem initiating the registration process",
+        "There was an issue completing the process. The verification token might have expired": "There was an issue completing the process. The verification token might have expired.",
+        "There was an issue initiating the password reset process": "There was an issue initiating the password reset process.",
+        "There was an issue resetting the password": "There was an issue resetting the password",
+        "There was an issue signing out": "There was an issue signing out",
+        "Time-based One-Time Password": "Time-based One-Time Password",
+        "Username": "Username",
+        "You must open the link from the same device and browser that initiated the registration process": "You must open the link from the same device and browser that initiated the registration process",
+        "You're being signed out and redirected": "You're being signed out and redirected",
+        "Your supplied password does not meet the password policy requirements": "Your supplied password does not meet the password policy requirements."
+    }
+}
diff --git a/web/src/i18n/locales/es.json b/web/src/i18n/locales/es.json
new file mode 100644
index 00000000..30c23e7a
--- /dev/null
+++ b/web/src/i18n/locales/es.json
@@ -0,0 +1,53 @@
+{
+    "Portal": {
+        "An email has been sent to your address to complete the process": "Un correo ha sido enviado a su cuenta para completar el proceso",
+        "Authenticated": "Autenticado",
+        "Cancel": "Cancelar",
+        "Contact your administrator to register a device": "Contacte a su administrador para registrar un dispositivo.",
+        "Could not obtain user settings": "Error al obtener configuración de usuario",
+        "Done": "Hecho",
+        "Enter new password": "Ingrese una nueva contraseña",
+        "Enter one-time password": "Ingrese contraseña de un solo uso (OTP)",
+        "Failed to register device, the provided link is expired or has already been used": "Error al registrar dispositivo, el link expiró o ya ha sido utilizado",
+        "Hi": "Hola",
+        "Incorrect username or password": "Usuario y/o contraseña incorrectos",
+        "Loading": "Cargando",
+        "Logout": "Cerrar Sesión",
+        "Lost your device?": "Perdió su dispositivo?",
+        "Methods": "Métodos",
+        "Need Google Authenticator?": "Necesita Google Authenticator?",
+        "New password": "Nueva contraseña",
+        "No verification token provided": "No se ha recibido el token de verificación",
+        "OTP Secret copied to clipboard": "La clave OTP ha sido copiada al portapapeles",
+        "OTP URL copied to clipboard": "la URL OTP ha sido copiada al portapapeles.",
+        "One-Time Password": "Contraseña de un solo uso (OTP)",
+        "Password has been reset": "La contraseña ha sido restablecida.",
+        "Password": "Contraseña",
+        "Passwords do not match": "Las contraseñas no coinciden.",
+        "Push Notification": "Notificaciones Push",
+        "Register device": "Registrar Dispositivo",
+        "Register your first device by clicking on the link below": "Registre su primer dispositivo, haciendo click en el siguiente link.",
+        "Remember me": "Recordarme",
+        "Repeat new password": "Repetir la contraseña",
+        "Reset password": "Restablecer Contraseña",
+        "Reset password?": "Olvidé mi contraseña",
+        "Reset": "Restablecer",
+        "Scan QR Code": "Escanear Código QR",
+        "Secret": "Secreto",
+        "Security Key - U2F": "Llave de Seguridad - U2F",
+        "Select a Device": "Seleccionar Dispositivo",
+        "Sign in": "Iniciar Sesión",
+        "Sign out": "Cerrar Sesión",
+        "The resource you're attempting to access requires two-factor authentication": "El recurso que intenta alcanzar requiere un segundo factor de autenticación (2FA).",
+        "There was a problem initiating the registration process": "Ocurrió un problema al iniciar el proceso de registración",
+        "There was an issue completing the process. The verification token might have expired": "Ocurrió un problema mientras se completaba el proceso. El token de verificación pudo haber expirado.",
+        "There was an issue initiating the password reset process": "Ha ocurrido un error al iniciar el proceso de proceso de restauración de contraseña.",
+        "There was an issue resetting the password": "Ocurrió un error al intentar restablecer la contraseña",
+        "There was an issue signing out": "Ocurrió un error al intentar cerrar sesión",
+        "Time-based One-Time Password": "Contraseña de uso único - OTP",
+        "Username": "Usuario",
+        "You must open the link from the same device and browser that initiated the registration process": "Debe abrir el link desde el mismo dispositivo y navegador desde el que inició el proceso de registración",
+        "You're being signed out and redirected": "Cerrando Sesión y redirigiendo",
+        "Your supplied password does not meet the password policy requirements": "La contraseña suministrada no cumple con los requerimientos de la política de contraseñas"
+    }
+}
diff --git a/web/src/index.tsx b/web/src/index.tsx
index f306ac25..ee2d6014 100644
--- a/web/src/index.tsx
+++ b/web/src/index.tsx
@@ -5,6 +5,7 @@ import ReactDOM from "react-dom";
 import "@root/index.css";
 import App from "@root/App";
 import * as serviceWorker from "@root/serviceWorker";
+import "./i18n/index.ts";
 
 ReactDOM.render(<App />, document.getElementById("root"));
 
diff --git a/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx b/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx
index 560ebd47..b26eca2b 100644
--- a/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx
+++ b/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx
@@ -6,6 +6,7 @@ import { makeStyles, Typography, Button, IconButton, Link, CircularProgress, Tex
 import { red } from "@material-ui/core/colors";
 import classnames from "classnames";
 import QRCode from "qrcode.react";
+import { useTranslation } from "react-i18next";
 import { useLocation, useNavigate } from "react-router-dom";
 
 import AppStoreBadges from "@components/AppStoreBadges";
@@ -26,6 +27,7 @@ const RegisterOneTimePassword = function () {
     const { createSuccessNotification, createErrorNotification } = useNotifications();
     const [hasErrored, setHasErrored] = useState(false);
     const [isLoading, setIsLoading] = useState(false);
+    const { t: translate } = useTranslation("Portal");
 
     // Get the token from the query param to give it back to the API when requesting
     // the secret for OTP.
@@ -49,17 +51,19 @@ const RegisterOneTimePassword = function () {
             console.error(err);
             if ((err as Error).message.includes("Request failed with status code 403")) {
                 createErrorNotification(
-                    "You must open the link from the same device and browser that initiated the registration process",
+                    translate(
+                        "You must open the link from the same device and browser that initiated the registration process",
+                    ),
                 );
             } else {
                 createErrorNotification(
-                    "Failed to register device, the provided link is expired or has already been used",
+                    translate("Failed to register device, the provided link is expired or has already been used"),
                 );
             }
             setHasErrored(true);
         }
         setIsLoading(false);
-    }, [processToken, createErrorNotification]);
+    }, [processToken, createErrorNotification, translate]);
 
     useEffect(() => {
         completeRegistrationProcess();
@@ -82,10 +86,12 @@ const RegisterOneTimePassword = function () {
     const qrcodeFuzzyStyle = isLoading || hasErrored ? style.fuzzy : undefined;
 
     return (
-        <LoginLayout title="Scan QR Code">
+        <LoginLayout title={translate("Scan QR Code")}>
             <div className={style.root}>
                 <div className={style.googleAuthenticator}>
-                    <Typography className={style.googleAuthenticatorText}>Need Google Authenticator?</Typography>
+                    <Typography className={style.googleAuthenticatorText}>
+                        {translate("Need Google Authenticator?")}
+                    </Typography>
                     <AppStoreBadges
                         iconSize={128}
                         targetBlank
@@ -105,7 +111,7 @@ const RegisterOneTimePassword = function () {
                     {secretURL !== "empty" ? (
                         <TextField
                             id="secret-url"
-                            label="Secret"
+                            label={translate("Secret")}
                             className={style.secret}
                             value={secretURL}
                             InputProps={{
@@ -113,8 +119,12 @@ const RegisterOneTimePassword = function () {
                             }}
                         />
                     ) : null}
-                    {secretBase32 ? SecretButton(secretBase32, "OTP Secret copied to clipboard.", faKey) : null}
-                    {secretURL !== "empty" ? SecretButton(secretURL, "OTP URL copied to clipboard.", faCopy) : null}
+                    {secretBase32
+                        ? SecretButton(secretBase32, translate("OTP Secret copied to clipboard"), faKey)
+                        : null}
+                    {secretURL !== "empty"
+                        ? SecretButton(secretURL, translate("OTP URL copied to clipboard"), faCopy)
+                        : null}
                 </div>
                 <Button
                     variant="contained"
@@ -123,7 +133,7 @@ const RegisterOneTimePassword = function () {
                     onClick={handleDoneClick}
                     disabled={isLoading}
                 >
-                    Done
+                    {translate("Done")}
                 </Button>
             </div>
         </LoginLayout>
diff --git a/web/src/views/LoadingPage/LoadingPage.tsx b/web/src/views/LoadingPage/LoadingPage.tsx
index a37dd836..da7d0e52 100644
--- a/web/src/views/LoadingPage/LoadingPage.tsx
+++ b/web/src/views/LoadingPage/LoadingPage.tsx
@@ -1,15 +1,17 @@
 import React from "react";
 
 import { useTheme, Typography, Grid } from "@material-ui/core";
+import { useTranslation } from "react-i18next";
 import ReactLoading from "react-loading";
 
 const LoadingPage = function () {
     const theme = useTheme();
+    const { t: translate } = useTranslation("Portal");
     return (
         <Grid container alignItems="center" justifyContent="center" style={{ minHeight: "100vh" }}>
             <Grid item style={{ textAlign: "center", display: "inline-block" }}>
                 <ReactLoading width={64} height={64} color={theme.custom.loadingBar} type="bars" />
-                <Typography>Loading...</Typography>
+                <Typography>{translate("Loading")}...</Typography>
             </Grid>
         </Grid>
     );
diff --git a/web/src/views/LoginPortal/Authenticated.tsx b/web/src/views/LoginPortal/Authenticated.tsx
index 1f99807b..aa2f4dd9 100644
--- a/web/src/views/LoginPortal/Authenticated.tsx
+++ b/web/src/views/LoginPortal/Authenticated.tsx
@@ -1,17 +1,19 @@
 import React from "react";
 
 import { Typography, makeStyles } from "@material-ui/core";
+import { useTranslation } from "react-i18next";
 
 import SuccessIcon from "@components/SuccessIcon";
 
 const Authenticated = function () {
     const classes = useStyles();
+    const { t: translate } = useTranslation("Portal");
     return (
         <div id="authenticated-stage">
             <div className={classes.iconContainer}>
                 <SuccessIcon />
             </div>
-            <Typography>Authenticated</Typography>
+            <Typography>{translate("Authenticated")}</Typography>
         </div>
     );
 };
diff --git a/web/src/views/LoginPortal/AuthenticatedView/AuthenticatedView.tsx b/web/src/views/LoginPortal/AuthenticatedView/AuthenticatedView.tsx
index 40c1241c..15a73d62 100644
--- a/web/src/views/LoginPortal/AuthenticatedView/AuthenticatedView.tsx
+++ b/web/src/views/LoginPortal/AuthenticatedView/AuthenticatedView.tsx
@@ -1,6 +1,7 @@
 import React from "react";
 
 import { Grid, makeStyles, Button } from "@material-ui/core";
+import { useTranslation } from "react-i18next";
 import { useNavigate } from "react-router-dom";
 
 import { LogoutRoute as SignOutRoute } from "@constants/Routes";
@@ -14,17 +15,18 @@ export interface Props {
 const AuthenticatedView = function (props: Props) {
     const style = useStyles();
     const navigate = useNavigate();
+    const { t: translate } = useTranslation("Portal");
 
     const handleLogoutClick = () => {
         navigate(SignOutRoute);
     };
 
     return (
-        <LoginLayout id="authenticated-stage" title={`Hi ${props.name}`} showBrand>
+        <LoginLayout id="authenticated-stage" title={`${translate("Hi")} ${props.name}`} showBrand>
             <Grid container>
                 <Grid item xs={12}>
                     <Button color="secondary" onClick={handleLogoutClick} id="logout-button">
-                        Logout
+                        {translate("Logout")}
                     </Button>
                 </Grid>
                 <Grid item xs={12} className={style.mainContainer}>
diff --git a/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx b/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx
index 43e62e2b..df92f061 100644
--- a/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx
+++ b/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx
@@ -2,6 +2,7 @@ import React, { MutableRefObject, useEffect, useRef, useState } from "react";
 
 import { makeStyles, Grid, Button, FormControlLabel, Checkbox, Link } from "@material-ui/core";
 import classnames from "classnames";
+import { useTranslation } from "react-i18next";
 import { useNavigate } from "react-router-dom";
 
 import FixedTextField from "@components/FixedTextField";
@@ -37,6 +38,7 @@ const FirstFactorForm = function (props: Props) {
     // TODO (PR: #806, Issue: #511) potentially refactor
     const usernameRef = useRef() as MutableRefObject<HTMLInputElement>;
     const passwordRef = useRef() as MutableRefObject<HTMLInputElement>;
+    const { t: translate } = useTranslation("Portal");
     useEffect(() => {
         const timeout = setTimeout(() => usernameRef.current.focus(), 10);
         return () => clearTimeout(timeout);
@@ -66,7 +68,7 @@ const FirstFactorForm = function (props: Props) {
             props.onAuthenticationSuccess(res ? res.redirect : undefined);
         } catch (err) {
             console.error(err);
-            createErrorNotification("Incorrect username or password.");
+            createErrorNotification(translate("Incorrect username or password"));
             props.onAuthenticationFailure();
             setPassword("");
             passwordRef.current.focus();
@@ -78,14 +80,14 @@ const FirstFactorForm = function (props: Props) {
     };
 
     return (
-        <LoginLayout id="first-factor-stage" title="Sign in" showBrand>
+        <LoginLayout id="first-factor-stage" title={translate("Sign in")} showBrand>
             <Grid container spacing={2}>
                 <Grid item xs={12}>
                     <FixedTextField
                         // TODO (PR: #806, Issue: #511) potentially refactor
                         inputRef={usernameRef}
                         id="username-textfield"
-                        label="Username"
+                        label={translate("Username")}
                         variant="outlined"
                         required
                         value={username}
@@ -115,7 +117,7 @@ const FirstFactorForm = function (props: Props) {
                         // TODO (PR: #806, Issue: #511) potentially refactor
                         inputRef={passwordRef}
                         id="password-textfield"
-                        label="Password"
+                        label={translate("Password")}
                         variant="outlined"
                         required
                         fullWidth
@@ -163,7 +165,7 @@ const FirstFactorForm = function (props: Props) {
                                 />
                             }
                             className={style.rememberMe}
-                            label="Remember me"
+                            label={translate("Remember me")}
                         />
                     </Grid>
                 ) : null}
@@ -176,7 +178,7 @@ const FirstFactorForm = function (props: Props) {
                         disabled={disabled}
                         onClick={handleSignIn}
                     >
-                        Sign in
+                        {translate("Sign in")}
                     </Button>
                 </Grid>
                 {props.resetPassword ? (
@@ -187,7 +189,7 @@ const FirstFactorForm = function (props: Props) {
                             onClick={handleResetPasswordClick}
                             className={style.resetLink}
                         >
-                            Reset password?
+                            {translate("Reset password?")}
                         </Link>
                     </Grid>
                 ) : null}
diff --git a/web/src/views/LoginPortal/SecondFactor/MethodContainer.tsx b/web/src/views/LoginPortal/SecondFactor/MethodContainer.tsx
index 6874a50c..1d36b180 100644
--- a/web/src/views/LoginPortal/SecondFactor/MethodContainer.tsx
+++ b/web/src/views/LoginPortal/SecondFactor/MethodContainer.tsx
@@ -2,6 +2,7 @@ import React, { ReactNode, Fragment } from "react";
 
 import { makeStyles, Typography, Link, useTheme } from "@material-ui/core";
 import classnames from "classnames";
+import { useTranslation } from "react-i18next";
 
 import InformationIcon from "@components/InformationIcon";
 import Authenticated from "@views/LoginPortal/Authenticated";
@@ -27,12 +28,13 @@ export interface Props {
 
 const DefaultMethodContainer = function (props: Props) {
     const style = useStyles();
+    const { t: translate } = useTranslation("Portal");
     const registerMessage = props.registered
         ? props.title === "Push Notification"
             ? ""
-            : "Lost your device?"
-        : "Register device";
-    const selectMessage = "Select a Device";
+            : translate("Lost your device?")
+        : translate("Register device");
+    const selectMessage = translate("Select a Device");
 
     let container: ReactNode;
     let stateClass: string = "";
@@ -95,6 +97,7 @@ interface NotRegisteredContainerProps {
 }
 
 function NotRegisteredContainer(props: NotRegisteredContainerProps) {
+    const { t: translate } = useTranslation("Portal");
     const theme = useTheme();
     return (
         <Fragment>
@@ -102,14 +105,14 @@ function NotRegisteredContainer(props: NotRegisteredContainerProps) {
                 <InformationIcon />
             </div>
             <Typography style={{ color: "#5858ff" }}>
-                The resource you're attempting to access requires two-factor authentication.
+                {translate("The resource you're attempting to access requires two-factor authentication")}
             </Typography>
             <Typography style={{ color: "#5858ff" }}>
                 {props.title === "Push Notification"
                     ? props.duoSelfEnrollment
-                        ? "Register your first device by clicking on the link below."
-                        : "Contact your administrator to register a device."
-                    : "Register your first device by clicking on the link below."}
+                        ? translate("Register your first device by clicking on the link below")
+                        : translate("Contact your administrator to register a device.")
+                    : translate("Register your first device by clicking on the link below")}
             </Typography>
         </Fragment>
     );
diff --git a/web/src/views/LoginPortal/SecondFactor/MethodSelectionDialog.tsx b/web/src/views/LoginPortal/SecondFactor/MethodSelectionDialog.tsx
index 1ba5d903..ce9cb697 100644
--- a/web/src/views/LoginPortal/SecondFactor/MethodSelectionDialog.tsx
+++ b/web/src/views/LoginPortal/SecondFactor/MethodSelectionDialog.tsx
@@ -10,6 +10,7 @@ import {
     Typography,
     useTheme,
 } from "@material-ui/core";
+import { useTranslation } from "react-i18next";
 
 import FingerTouchIcon from "@components/FingerTouchIcon";
 import PushNotificationIcon from "@components/PushNotificationIcon";
@@ -28,6 +29,7 @@ export interface Props {
 const MethodSelectionDialog = function (props: Props) {
     const style = useStyles();
     const theme = useTheme();
+    const { t: translate } = useTranslation("Portal");
 
     const pieChartIcon = (
         <TimerIcon width={24} height={24} period={15} color={theme.palette.primary.main} backgroundColor={"white"} />
@@ -40,7 +42,7 @@ const MethodSelectionDialog = function (props: Props) {
                     {props.methods.has(SecondFactorMethod.TOTP) ? (
                         <MethodItem
                             id="one-time-password-option"
-                            method="Time-based One-Time Password"
+                            method={translate("Time-based One-Time Password")}
                             icon={pieChartIcon}
                             onClick={() => props.onClick(SecondFactorMethod.TOTP)}
                         />
@@ -48,7 +50,7 @@ const MethodSelectionDialog = function (props: Props) {
                     {props.methods.has(SecondFactorMethod.U2F) && props.u2fSupported ? (
                         <MethodItem
                             id="security-key-option"
-                            method="Security Key - U2F"
+                            method={translate("Security Key - U2F")}
                             icon={<FingerTouchIcon size={32} />}
                             onClick={() => props.onClick(SecondFactorMethod.U2F)}
                         />
@@ -56,7 +58,7 @@ const MethodSelectionDialog = function (props: Props) {
                     {props.methods.has(SecondFactorMethod.MobilePush) ? (
                         <MethodItem
                             id="push-notification-option"
-                            method="Push Notification"
+                            method={translate("Push Notification")}
                             icon={<PushNotificationIcon width={32} height={32} />}
                             onClick={() => props.onClick(SecondFactorMethod.MobilePush)}
                         />
diff --git a/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx b/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx
index c0920c2a..d8f38a77 100644
--- a/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx
+++ b/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx
@@ -1,5 +1,7 @@
 import React, { useCallback, useEffect, useRef, useState } from "react";
 
+import { useTranslation } from "react-i18next";
+
 import { useRedirectionURL } from "@hooks/RedirectionURL";
 import { useUserInfoTOTPConfiguration } from "@hooks/UserInfoTOTPConfiguration";
 import { completeTOTPSignIn } from "@services/OneTimePassword";
@@ -31,20 +33,20 @@ const OneTimePasswordMethod = function (props: Props) {
         props.authenticationLevel === AuthenticationLevel.TwoFactor ? State.Success : State.Idle,
     );
     const redirectionURL = useRedirectionURL();
+    const { t: translate } = useTranslation("Portal");
 
     const { onSignInSuccess, onSignInError } = props;
     const onSignInErrorCallback = useRef(onSignInError).current;
     const onSignInSuccessCallback = useRef(onSignInSuccess).current;
-
     const [resp, fetch, , err] = useUserInfoTOTPConfiguration();
 
     useEffect(() => {
         if (err) {
             console.error(err);
-            onSignInErrorCallback(new Error("Could not obtain user settings"));
+            onSignInErrorCallback(new Error(translate("Could not obtain user settings")));
             setState(State.Failure);
         }
-    }, [onSignInErrorCallback, err]);
+    }, [onSignInErrorCallback, err, translate]);
 
     useEffect(() => {
         if (props.registered && props.authenticationLevel === AuthenticationLevel.OneFactor) {
@@ -105,8 +107,8 @@ const OneTimePasswordMethod = function (props: Props) {
     return (
         <MethodContainer
             id={props.id}
-            title="One-Time Password"
-            explanation="Enter one-time password"
+            title={translate("One-Time Password")}
+            explanation={translate("Enter one-time password")}
             duoSelfEnrollment={false}
             registered={props.registered}
             state={methodState}
diff --git a/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx b/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx
index 97c266da..605cf954 100644
--- a/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx
+++ b/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx
@@ -1,6 +1,7 @@
 import React, { useState, useEffect } from "react";
 
 import { Grid, makeStyles, Button } from "@material-ui/core";
+import { useTranslation } from "react-i18next";
 import { Route, Routes, useNavigate } from "react-router-dom";
 import u2fApi from "u2f-api";
 
@@ -23,7 +24,7 @@ import OneTimePasswordMethod from "@views/LoginPortal/SecondFactor/OneTimePasswo
 import PushNotificationMethod from "@views/LoginPortal/SecondFactor/PushNotificationMethod";
 import SecurityKeyMethod from "@views/LoginPortal/SecondFactor/SecurityKeyMethod";
 
-const EMAIL_SENT_NOTIFICATION = "An email has been sent to your address to complete the process.";
+const EMAIL_SENT_NOTIFICATION = "An email has been sent to your address to complete the process";
 
 export interface Props {
     authenticationLevel: AuthenticationLevel;
@@ -42,6 +43,7 @@ const SecondFactorForm = function (props: Props) {
     const { createInfoNotification, createErrorNotification } = useNotifications();
     const [registrationInProgress, setRegistrationInProgress] = useState(false);
     const [u2fSupported, setU2fSupported] = useState(false);
+    const { t: translate } = useTranslation("Portal");
 
     // Check that U2F is supported.
     useEffect(() => {
@@ -59,10 +61,10 @@ const SecondFactorForm = function (props: Props) {
             setRegistrationInProgress(true);
             try {
                 await initiateRegistrationFunc();
-                createInfoNotification(EMAIL_SENT_NOTIFICATION);
+                createInfoNotification(translate(EMAIL_SENT_NOTIFICATION));
             } catch (err) {
                 console.error(err);
-                createErrorNotification("There was a problem initiating the registration process");
+                createErrorNotification(translate("There was a problem initiating the registration process"));
             }
             setRegistrationInProgress(false);
         };
@@ -88,7 +90,7 @@ const SecondFactorForm = function (props: Props) {
     };
 
     return (
-        <LoginLayout id="second-factor-stage" title={`Hi ${props.userInfo.display_name}`} showBrand>
+        <LoginLayout id="second-factor-stage" title={`${translate("Hi")} ${props.userInfo.display_name}`} showBrand>
             <MethodSelectionDialog
                 open={methodSelectionOpen}
                 methods={props.configuration.available_methods}
@@ -99,11 +101,11 @@ const SecondFactorForm = function (props: Props) {
             <Grid container>
                 <Grid item xs={12}>
                     <Button color="secondary" onClick={handleLogoutClick} id="logout-button">
-                        Logout
+                        {translate("Logout")}
                     </Button>
                     {" | "}
                     <Button color="secondary" onClick={handleMethodSelectionClick} id="methods-button">
-                        Methods
+                        {translate("Methods")}
                     </Button>
                 </Grid>
                 <Grid item xs={12} className={style.methodContainer}>
diff --git a/web/src/views/LoginPortal/SignOut/SignOut.tsx b/web/src/views/LoginPortal/SignOut/SignOut.tsx
index 2c77df1c..4346f76d 100644
--- a/web/src/views/LoginPortal/SignOut/SignOut.tsx
+++ b/web/src/views/LoginPortal/SignOut/SignOut.tsx
@@ -1,6 +1,7 @@
 import React, { useEffect, useCallback, useState } from "react";
 
 import { Typography, makeStyles } from "@material-ui/core";
+import { useTranslation } from "react-i18next";
 import { Navigate } from "react-router-dom";
 
 import { FirstFactorRoute } from "@constants/Routes";
@@ -21,6 +22,7 @@ const SignOut = function (props: Props) {
     const redirector = useRedirector();
     const [timedOut, setTimedOut] = useState(false);
     const [safeRedirect, setSafeRedirect] = useState(false);
+    const { t: translate } = useTranslation("Portal");
 
     const doSignOut = useCallback(async () => {
         try {
@@ -36,9 +38,9 @@ const SignOut = function (props: Props) {
             }, 2000);
         } catch (err) {
             console.error(err);
-            createErrorNotification("There was an issue signing out");
+            createErrorNotification(translate("There was an issue signing out"));
         }
-    }, [createErrorNotification, redirectionURL, setSafeRedirect, setTimedOut, mounted]);
+    }, [createErrorNotification, redirectionURL, setSafeRedirect, setTimedOut, mounted, translate]);
 
     useEffect(() => {
         doSignOut();
@@ -53,8 +55,8 @@ const SignOut = function (props: Props) {
     }
 
     return (
-        <LoginLayout title="Sign out">
-            <Typography className={style.typo}>You're being signed out and redirected...</Typography>
+        <LoginLayout title={translate("Sign out")}>
+            <Typography className={style.typo}>{translate("You're being signed out and redirected")}...</Typography>
         </LoginLayout>
     );
 };
diff --git a/web/src/views/ResetPassword/ResetPasswordStep1.tsx b/web/src/views/ResetPassword/ResetPasswordStep1.tsx
index 605ec5e9..ce6f7718 100644
--- a/web/src/views/ResetPassword/ResetPasswordStep1.tsx
+++ b/web/src/views/ResetPassword/ResetPasswordStep1.tsx
@@ -1,6 +1,7 @@
 import React, { useState } from "react";
 
 import { Grid, Button, makeStyles } from "@material-ui/core";
+import { useTranslation } from "react-i18next";
 import { useNavigate } from "react-router-dom";
 
 import FixedTextField from "@components/FixedTextField";
@@ -15,6 +16,7 @@ const ResetPasswordStep1 = function () {
     const [error, setError] = useState(false);
     const { createInfoNotification, createErrorNotification } = useNotifications();
     const navigate = useNavigate();
+    const { t: translate } = useTranslation("Portal");
 
     const doInitiateResetPasswordProcess = async () => {
         if (username === "") {
@@ -24,9 +26,9 @@ const ResetPasswordStep1 = function () {
 
         try {
             await initiateResetPasswordProcess(username);
-            createInfoNotification("An email has been sent to your address to complete the process.");
+            createInfoNotification(translate("An email has been sent to your address to complete the process"));
         } catch (err) {
-            createErrorNotification("There was an issue initiating the password reset process.");
+            createErrorNotification(translate("There was an issue initiating the password reset process"));
         }
     };
 
@@ -39,12 +41,12 @@ const ResetPasswordStep1 = function () {
     };
 
     return (
-        <LoginLayout title="Reset password" id="reset-password-step1-stage">
+        <LoginLayout title={translate("Reset password")} id="reset-password-step1-stage">
             <Grid container className={style.root} spacing={2}>
                 <Grid item xs={12}>
                     <FixedTextField
                         id="username-textfield"
-                        label="Username"
+                        label={translate("Username")}
                         variant="outlined"
                         fullWidth
                         error={error}
@@ -60,7 +62,7 @@ const ResetPasswordStep1 = function () {
                 </Grid>
                 <Grid item xs={6}>
                     <Button id="reset-button" variant="contained" color="primary" fullWidth onClick={handleResetClick}>
-                        Reset
+                        {translate("Reset")}
                     </Button>
                 </Grid>
                 <Grid item xs={6}>
@@ -71,7 +73,7 @@ const ResetPasswordStep1 = function () {
                         fullWidth
                         onClick={handleCancelClick}
                     >
-                        Cancel
+                        {translate("Cancel")}
                     </Button>
                 </Grid>
             </Grid>
diff --git a/web/src/views/ResetPassword/ResetPasswordStep2.tsx b/web/src/views/ResetPassword/ResetPasswordStep2.tsx
index 6b43b0c5..eb0f96b1 100644
--- a/web/src/views/ResetPassword/ResetPasswordStep2.tsx
+++ b/web/src/views/ResetPassword/ResetPasswordStep2.tsx
@@ -2,6 +2,7 @@ import React, { useState, useCallback, useEffect } from "react";
 
 import { Grid, Button, makeStyles } from "@material-ui/core";
 import classnames from "classnames";
+import { useTranslation } from "react-i18next";
 import { useLocation, useNavigate } from "react-router-dom";
 
 import FixedTextField from "@components/FixedTextField";
@@ -20,6 +21,7 @@ const ResetPasswordStep2 = function () {
     const [errorPassword1, setErrorPassword1] = useState(false);
     const [errorPassword2, setErrorPassword2] = useState(false);
     const { createSuccessNotification, createErrorNotification } = useNotifications();
+    const { t: translate } = useTranslation("Portal");
     const navigate = useNavigate();
     // Get the token from the query param to give it back to the API when requesting
     // the secret for OTP.
@@ -28,7 +30,7 @@ const ResetPasswordStep2 = function () {
     const completeProcess = useCallback(async () => {
         if (!processToken) {
             setFormDisabled(true);
-            createErrorNotification("No verification token provided");
+            createErrorNotification(translate("No verification token provided"));
             return;
         }
 
@@ -39,11 +41,11 @@ const ResetPasswordStep2 = function () {
         } catch (err) {
             console.error(err);
             createErrorNotification(
-                "There was an issue completing the process. The verification token might have expired.",
+                translate("There was an issue completing the process. The verification token might have expired"),
             );
             setFormDisabled(true);
         }
-    }, [processToken, createErrorNotification]);
+    }, [processToken, createErrorNotification, translate]);
 
     useEffect(() => {
         completeProcess();
@@ -62,21 +64,23 @@ const ResetPasswordStep2 = function () {
         if (password1 !== password2) {
             setErrorPassword1(true);
             setErrorPassword2(true);
-            createErrorNotification("Passwords do not match.");
+            createErrorNotification(translate("Passwords do not match"));
             return;
         }
 
         try {
             await resetPassword(password1);
-            createSuccessNotification("Password has been reset.");
+            createSuccessNotification(translate("Password has been reset"));
             setTimeout(() => navigate(FirstFactorRoute), 1500);
             setFormDisabled(true);
         } catch (err) {
             console.error(err);
             if ((err as Error).message.includes("0000052D.")) {
-                createErrorNotification("Your supplied password does not meet the password policy requirements.");
+                createErrorNotification(
+                    translate("Your supplied password does not meet the password policy requirements"),
+                );
             } else {
-                createErrorNotification("There was an issue resetting the password.");
+                createErrorNotification(translate("There was an issue resetting the password"));
             }
         }
     };
@@ -86,12 +90,12 @@ const ResetPasswordStep2 = function () {
     const handleCancelClick = () => navigate(FirstFactorRoute);
 
     return (
-        <LoginLayout title="Enter new password" id="reset-password-step2-stage">
+        <LoginLayout title={translate("Enter new password")} id="reset-password-step2-stage">
             <Grid container className={style.root} spacing={2}>
                 <Grid item xs={12}>
                     <FixedTextField
                         id="password1-textfield"
-                        label="New password"
+                        label={translate("New password")}
                         variant="outlined"
                         type="password"
                         value={password1}
@@ -105,7 +109,7 @@ const ResetPasswordStep2 = function () {
                 <Grid item xs={12}>
                     <FixedTextField
                         id="password2-textfield"
-                        label="Repeat new password"
+                        label={translate("Repeat new password")}
                         variant="outlined"
                         type="password"
                         disabled={formDisabled}
@@ -132,7 +136,7 @@ const ResetPasswordStep2 = function () {
                         onClick={handleResetClick}
                         className={style.fullWidth}
                     >
-                        Reset
+                        {translate("Reset")}
                     </Button>
                 </Grid>
                 <Grid item xs={6}>
@@ -144,7 +148,7 @@ const ResetPasswordStep2 = function () {
                         onClick={handleCancelClick}
                         className={style.fullWidth}
                     >
-                        Cancel
+                        {translate("Cancel")}
                     </Button>
                 </Grid>
             </Grid>
diff --git a/web/tsconfig.json b/web/tsconfig.json
index 696d414d..4d1c4cba 100644
--- a/web/tsconfig.json
+++ b/web/tsconfig.json
@@ -7,6 +7,7 @@
       "@components/*": ["components/*"],
       "@constants/*": ["constants/*"],
       "@hooks/*": ["hooks/*"],
+      "@i18n/*": ["i18n/*"],
       "@layouts/*": ["layouts/*"],
       "@models/*": ["models/*"],
       "@services/*": ["services/*"],