diff --git a/Cargo.lock b/Cargo.lock
index 694e857..54efba0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -483,6 +483,22 @@ dependencies = [
  "shlex",
 ]
 
+[[package]]
+name = "cf-turnstile"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3e7381ca451b439579a09feb1d41d4b07c0e903bf8c74602b9e95219c40e47b"
+dependencies = [
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "secrecy",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+]
+
 [[package]]
 name = "cfg-expr"
 version = "0.15.8"
@@ -638,6 +654,16 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "core-foundation"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
 [[package]]
 name = "core-foundation-sys"
 version = "0.8.7"
@@ -1236,6 +1262,7 @@ dependencies = [
  "hyper",
  "hyper-util",
  "rustls",
+ "rustls-native-certs",
  "rustls-pki-types",
  "tokio",
  "tokio-rustls",
@@ -1799,7 +1826,7 @@ dependencies = [
  "openssl-probe",
  "openssl-sys",
  "schannel",
- "security-framework",
+ "security-framework 2.11.1",
  "security-framework-sys",
  "tempfile",
 ]
@@ -2592,12 +2619,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "822ee9188ac4ec04a2f0531e55d035fb2de73f18b41a63c70c2712503b6fb13c"
 dependencies = [
  "once_cell",
+ "ring",
  "rustls-pki-types",
  "rustls-webpki",
  "subtle",
  "zeroize",
 ]
 
+[[package]]
+name = "rustls-native-certs"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3"
+dependencies = [
+ "openssl-probe",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework 3.2.0",
+]
+
 [[package]]
 name = "rustls-pemfile"
 version = "2.2.0"
@@ -2660,6 +2700,15 @@ version = "1.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
 
+[[package]]
+name = "secrecy"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e"
+dependencies = [
+ "zeroize",
+]
+
 [[package]]
 name = "security-framework"
 version = "2.11.1"
@@ -2667,7 +2716,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
 dependencies = [
  "bitflags 2.9.0",
- "core-foundation",
+ "core-foundation 0.9.4",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316"
+dependencies = [
+ "bitflags 2.9.0",
+ "core-foundation 0.10.0",
  "core-foundation-sys",
  "libc",
  "security-framework-sys",
@@ -2961,7 +3023,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
 dependencies = [
  "bitflags 2.9.0",
- "core-foundation",
+ "core-foundation 0.9.4",
  "system-configuration-sys",
 ]
 
@@ -3056,6 +3118,7 @@ version = "0.1.0"
 dependencies = [
  "axum",
  "axum-extra",
+ "cf-turnstile",
  "image",
  "mime_guess",
  "pathbufd",
diff --git a/README.md b/README.md
index a796e9e..5423b10 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,8 @@ Your first start of Tetratto might be a little slow as it's going to download al
 
 In the directory you're running Tetratto from, you should create a `tetratto.toml` file. This file follows the configuration schema defined [here](https://trisuaso.github.io/tetratto/tetratto/config/struct.Config.html)!
 
+Tetratto **requires** Cloudflare Turnstile for registrations. Testing keys are listed [here](https://developers.cloudflare.com/turnstile/troubleshooting/testing/). You can _technically_ disable the captcha by using the always passing, invisible keys.
+
 ## Usage (as a user)
 
 Tetratto is very simple once you get the hang of it! At the top of the page (or bottom if you're on mobile), you'll see the navigation bar. Once logged in, you'll be able to access "Home", "Popular", and "Communities" from there! You can also press your profile picture (on the right) to view your own profile, settings, or log out!
diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml
index 430c007..129570c 100644
--- a/crates/app/Cargo.toml
+++ b/crates/app/Cargo.toml
@@ -29,3 +29,4 @@ reqwest = { version = "0.12.15", features = ["json", "stream"] }
 regex = "1.11.1"
 serde_json = "1.0.140"
 mime_guess = "2.0.5"
+cf-turnstile = "0.2.0"
diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml
index da55be7..6a6c1cc 100644
--- a/crates/app/src/langs/en-US.toml
+++ b/crates/app/src/langs/en-US.toml
@@ -48,6 +48,8 @@ version = "1.0.0"
 "communities:action.select" = "Select"
 "communities:label.create_new" = "Create new community"
 "communities:label.name" = "Name"
+"communities:label.my_communities" = "My communities"
+"communities:label.popular_communities" = "Popular communities"
 "communities:action.join" = "Join"
 "communities:action.cancel_request" = "Cancel request"
 "communities:action.leave" = "Leave"
diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css
index 7c2ad8e..a62976e 100644
--- a/crates/app/src/public/css/style.css
+++ b/crates/app/src/public/css/style.css
@@ -149,6 +149,11 @@ article {
 }
 
 /* typo */
+ul,
+ol {
+    margin-left: 1rem;
+}
+
 pre,
 code {
     font-family: "Jetbrains Mono", "Fire Code", monospace;
diff --git a/crates/app/src/public/html/auth/register.html b/crates/app/src/public/html/auth/register.html
index cf9ac54..d0054b2 100644
--- a/crates/app/src/public/html/auth/register.html
+++ b/crates/app/src/public/html/auth/register.html
@@ -1,6 +1,11 @@
 {% extends "auth/base.html" %} {% block head %}
 <title>Register</title>
 {% endblock %} {% block title %}Register{% endblock %} {% block content %}
+<script
+    src="https://challenges.cloudflare.com/turnstile/v0/api.js"
+    defer
+></script>
+
 <form class="w-full flex flex-col gap-4" onsubmit="register(event)">
     <div class="flex flex-col gap-1">
         <label for="username"><b>Username</b></label>
@@ -24,6 +29,48 @@
         />
     </div>
 
+    <hr />
+
+    <div class="card-nest w-full">
+        <div class="card small flex items-center gap-2">
+            {{ icon "scroll-text" }}
+            <b>Policies</b>
+        </div>
+
+        <div class="card secondary flex flex-col gap-2">
+            <span>By continuing, you agree to the following policies:</span>
+
+            <ul>
+                <li>
+                    <a href="{{ config.policies.terms_of_service }}"
+                        >Terms of service</a
+                    >
+                </li>
+
+                <li>
+                    <a href="{{ config.policies.privacy }}">Privacy policy</a>
+                </li>
+            </ul>
+
+            <div class="flex gap-2">
+                <input
+                    type="checkbox"
+                    name="policy_consent"
+                    id="policy_consent"
+                    class="w-content"
+                    required
+                />
+                <label for="policy_consent">I agree</label>
+            </div>
+        </div>
+    </div>
+
+    <div
+        class="cf-turnstile"
+        data-sitekey="{{ config.turnstile.site_key }}"
+    ></div>
+
+    <hr />
     <button>Submit</button>
 </form>
 
@@ -38,6 +85,10 @@
             body: JSON.stringify({
                 username: e.target.username.value,
                 password: e.target.password.value,
+                policy_consent: e.target.policy_consent.checked,
+                captcha_response: e.target.querySelector(
+                    "[name=cf-turnstile-response]",
+                ).value,
             }),
         })
             .then((res) => res.json())
diff --git a/crates/app/src/public/html/communities/list.html b/crates/app/src/public/html/communities/list.html
index 6f9252a..fb3d42e 100644
--- a/crates/app/src/public/html/communities/list.html
+++ b/crates/app/src/public/html/communities/list.html
@@ -30,8 +30,31 @@
             </button>
         </form>
     </div>
-    {% endif %} {% for item in list %} {{
-    components::community_listing_card(community=item) }} {% endfor %}
+    {% endif %}
+
+    <div class="card-nest w-full">
+        <div class="card small flex items-center gap-2">
+            {{ icon "award" }}
+            <span>{{ text "communities:label.my_communities" }}</span>
+        </div>
+
+        <div class="card flex flex-col gap-2">
+            {% for item in list %} {{
+            components::community_listing_card(community=item) }} {% endfor %}
+        </div>
+    </div>
+
+    <div class="card-nest w-full">
+        <div class="card small flex items-center gap-2">
+            {{ icon "trending-up" }}
+            <span>{{ text "communities:label.popular_communities" }}</span>
+        </div>
+
+        <div class="card flex flex-col gap-2">
+            {% for item in popular_list %} {{
+            components::community_listing_card(community=item) }} {% endfor %}
+        </div>
+    </div>
 </main>
 
 <script>
diff --git a/crates/app/src/public/html/components.html b/crates/app/src/public/html/components.html
index 2d6d40f..f7ae592 100644
--- a/crates/app/src/public/html/components.html
+++ b/crates/app/src/public/html/components.html
@@ -51,7 +51,7 @@ community %}
 />
 {% endif %} {%- endmacro %} {% macro community_listing_card(community) -%}
 <a
-    class="card w-full flex items-center gap-4"
+    class="card secondary w-full flex items-center gap-4"
     href="/community/{{ community.title }}"
 >
     {{ components::community_avatar(id=community.id, community=community,
diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs
index d0a92d4..4aab726 100644
--- a/crates/app/src/routes/api/v1/auth/mod.rs
+++ b/crates/app/src/routes/api/v1/auth/mod.rs
@@ -3,7 +3,7 @@ pub mod ipbans;
 pub mod profile;
 pub mod social;
 
-use super::AuthProps;
+use super::{LoginProps, RegisterProps};
 use crate::{
     State, get_user_from_token,
     model::{ApiReturn, Error, auth::User},
@@ -16,12 +16,14 @@ use axum::{
 use axum_extra::extract::CookieJar;
 use tetratto_shared::hash::hash;
 
+use cf_turnstile::{SiteVerifyRequest, TurnstileClient};
+
 /// `/api/v1/auth/register`
 pub async fn register_request(
     headers: HeaderMap,
     jar: CookieJar,
     Extension(data): Extension<State>,
-    Json(props): Json<AuthProps>,
+    Json(props): Json<RegisterProps>,
 ) -> impl IntoResponse {
     let data = &(data.read().await).0;
     let user = get_user_from_token!(jar, data);
@@ -50,8 +52,31 @@ pub async fn register_request(
         return (None, Json(Error::NotAllowed.into()));
     }
 
+    // check captcha
+    let client = TurnstileClient::new(data.0.turnstile.secret_key.clone().into());
+
+    let validated = match client
+        .siteverify(SiteVerifyRequest {
+            response: props.captcha_response,
+            ..Default::default()
+        })
+        .await
+    {
+        Ok(v) => v,
+        Err(e) => return (None, Json(Error::MiscError(e.to_string()).into())),
+    };
+
+    if !validated.success | !props.policy_consent {
+        return (
+            None,
+            Json(Error::MiscError("Captcha failed".to_string()).into()),
+        );
+    }
+
     // ...
     let mut user = User::new(props.username, props.password);
+    user.settings.policy_consent = true;
+
     let (initial_token, t) = User::create_token(&real_ip);
     user.tokens.push(t);
 
@@ -81,7 +106,7 @@ pub async fn login_request(
     headers: HeaderMap,
     jar: CookieJar,
     Extension(data): Extension<State>,
-    Json(props): Json<AuthProps>,
+    Json(props): Json<LoginProps>,
 ) -> impl IntoResponse {
     let data = &(data.read().await).0;
     let user = get_user_from_token!(jar, data);
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index b787717..1abffcb 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -188,11 +188,19 @@ pub fn routes() -> Router {
 }
 
 #[derive(Deserialize)]
-pub struct AuthProps {
+pub struct LoginProps {
     pub username: String,
     pub password: String,
 }
 
+#[derive(Deserialize)]
+pub struct RegisterProps {
+    pub username: String,
+    pub password: String,
+    pub policy_consent: bool,
+    pub captcha_response: String,
+}
+
 #[derive(Deserialize)]
 pub struct CreateCommunity {
     pub title: String,
diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs
index 34c6df6..064f361 100644
--- a/crates/app/src/routes/pages/communities.rs
+++ b/crates/app/src/routes/pages/communities.rs
@@ -108,6 +108,11 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) ->
         Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
     };
 
+    let popular_list = match data.0.get_popular_communities().await {
+        Ok(p) => p,
+        Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
+    };
+
     let mut communities: Vec<Community> = Vec::new();
     for membership in &list {
         match data.0.get_community_by_id(membership.community).await {
@@ -118,7 +123,9 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) ->
 
     let lang = get_lang!(jar, data.0);
     let mut context = initial_context(&data.0.0, lang, &Some(user)).await;
+
     context.insert("list", &communities);
+    context.insert("popular_list", &popular_list);
 
     // return
     Ok(Html(
diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs
index 36d897e..f2e5036 100644
--- a/crates/core/src/config.rs
+++ b/crates/core/src/config.rs
@@ -109,6 +109,47 @@ impl Default for DatabaseConfig {
     }
 }
 
+/// Policies config (TOS/privacy)
+#[derive(Clone, Serialize, Deserialize, Debug)]
+pub struct PoliciesConfig {
+    /// The link to your terms of service page.
+    /// This is relative to `/auth/register` on the site.
+    ///
+    /// If your TOS is an HTML file located in `./public`, you can put
+    /// `/public/tos.html` here (or something).
+    pub terms_of_service: String,
+    /// The link to your privacy policy page.
+    /// This is relative to `/auth/register` on the site.
+    ///
+    /// Same deal as terms of service page.
+    pub privacy: String,
+}
+
+impl Default for PoliciesConfig {
+    fn default() -> Self {
+        Self {
+            terms_of_service: "/public/tos.html".to_string(),
+            privacy: "/public/privacy.html".to_string(),
+        }
+    }
+}
+
+/// Cloudflare Turnstile configuration
+#[derive(Clone, Serialize, Deserialize, Debug)]
+pub struct TurnstileConfig {
+    pub site_key: String,
+    pub secret_key: String,
+}
+
+impl Default for TurnstileConfig {
+    fn default() -> Self {
+        Self {
+            site_key: "1x00000000000000000000AA".to_string(), // always passing, visible
+            secret_key: "1x0000000000000000000000000000000AA".to_string(), // always passing
+        }
+    }
+}
+
 /// Configuration file
 #[derive(Clone, Serialize, Deserialize, Debug)]
 pub struct Config {
@@ -148,6 +189,12 @@ pub struct Config {
     /// A list of usernames which cannot be used. This also includes community names.
     #[serde(default = "default_banned_usernames")]
     pub banned_usernames: Vec<String>,
+    /// Configuration for your site's policies (terms of service, privacy).
+    #[serde(default = "default_policies")]
+    pub policies: PoliciesConfig,
+    /// Configuration for Cloudflare Turnstile.
+    #[serde(default = "default_turnstile")]
+    pub turnstile: TurnstileConfig,
 }
 
 fn default_name() -> String {
@@ -199,6 +246,14 @@ fn default_banned_usernames() -> Vec<String> {
     ]
 }
 
+fn default_policies() -> PoliciesConfig {
+    PoliciesConfig::default()
+}
+
+fn default_turnstile() -> TurnstileConfig {
+    TurnstileConfig::default()
+}
+
 impl Default for Config {
     fn default() -> Self {
         Self {
@@ -212,6 +267,8 @@ impl Default for Config {
             dirs: default_dirs(),
             no_track: default_no_track(),
             banned_usernames: default_banned_usernames(),
+            policies: default_policies(),
+            turnstile: default_turnstile(),
         }
     }
 }
diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs
index 125cb60..14af68d 100644
--- a/crates/core/src/database/communities.rs
+++ b/crates/core/src/database/communities.rs
@@ -9,7 +9,7 @@ use crate::model::{
     communities::{CommunityReadAccess, CommunityWriteAccess},
     permissions::FinePermission,
 };
-use crate::{auto_method, execute, get, query_row};
+use crate::{auto_method, execute, get, query_row, query_rows};
 use pathbufd::PathBufD;
 use std::fs::{exists, remove_file};
 
@@ -119,6 +119,32 @@ impl DataManager {
     auto_method!(get_community_by_id_no_void()@get_community_from_row -> "SELECT * FROM communities WHERE id = $1" --name="community" --returns=Community --cache-key-tmpl="atto.community:{}");
     auto_method!(get_community_by_title_no_void(&str)@get_community_from_row -> "SELECT * FROM communities WHERE title = $1" --name="community" --returns=Community --cache-key-tmpl="atto.community:{}");
 
+    /// Get the top 12 most popular (most likes) communities.
+    pub async fn get_popular_communities(&self) -> Result<Vec<Community>> {
+        let conn = match self.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        #[cfg(feature = "sqlite")]
+        let empty = [];
+        #[cfg(feature = "postgres")]
+        let empty = &[];
+
+        let res = query_rows!(
+            &conn,
+            "SELECT * FROM communities ORDER BY likes DESC LIMIT 12",
+            empty,
+            |x| { Self::get_community_from_row(x) }
+        );
+
+        if res.is_err() {
+            return Err(Error::GeneralNotFound("communities".to_string()));
+        }
+
+        Ok(res.unwrap())
+    }
+
     /// Create a new community in the database.
     ///
     /// # Arguments
diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs
index dae827d..1a3b818 100644
--- a/crates/core/src/model/auth.rs
+++ b/crates/core/src/model/auth.rs
@@ -41,6 +41,8 @@ impl Default for ThemePreference {
 
 #[derive(Clone, Debug, Serialize, Deserialize)]
 pub struct UserSettings {
+    #[serde(default)]
+    pub policy_consent: bool,
     #[serde(default)]
     pub display_name: String,
     #[serde(default)]
@@ -58,6 +60,7 @@ pub struct UserSettings {
 impl Default for UserSettings {
     fn default() -> Self {
         Self {
+            policy_consent: false,
             display_name: String::new(),
             biography: String::new(),
             private_profile: false,
diff --git a/example/tetratto.toml b/example/tetratto.toml
index c4f4304..aa49893 100644
--- a/example/tetratto.toml
+++ b/example/tetratto.toml
@@ -7,3 +7,7 @@ registration_enabled = true
 [dirs]
 templates = "html"
 assets = "public"
+
+[turnstile]
+site_key = "1x00000000000000000000AA"
+secret_key = "1x0000000000000000000000000000000AA"