diff --git a/src/main/java/org/patinanetwork/patchats/api/auth/security/SecurityConfig.java b/src/main/java/org/patinanetwork/patchats/api/auth/security/SecurityConfig.java
deleted file mode 100644
index 3b9bab1..0000000
--- a/src/main/java/org/patinanetwork/patchats/api/auth/security/SecurityConfig.java
+++ /dev/null
@@ -1,39 +0,0 @@
-package org.patinanetwork.patchats.api.auth.security;
-
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.context.annotation.Profile;
-import org.springframework.http.HttpMethod;
-import org.springframework.security.config.annotation.web.builders.HttpSecurity;
-import org.springframework.security.web.SecurityFilterChain;
-
-@Configuration
-public class SecurityConfig {
-
- /**
- * Default/production chain. Sending email is admin-only.
- *
- *
NOTE: authentication (OAuth2 login / roles) is not yet wired, so this rule currently fails closed — every
- * caller is denied because no security context is populated. Once the auth domain authenticates admins, the rule
- * begins to discriminate admins from other users. Other endpoints remain open for now, matching prior behaviour.
- */
- @Bean
- @Profile("!dev")
- SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
- return http.csrf(csrf -> csrf.disable())
- .authorizeHttpRequests(auth -> auth.requestMatchers(HttpMethod.POST, "/api/email/**")
- .hasRole("ADMIN")
- .anyRequest()
- .permitAll())
- .build();
- }
-
- /** Local-dev chain: everything is permitted so the email endpoint can be exercised without auth. */
- @Bean
- @Profile("dev")
- SecurityFilterChain devSecurityFilterChain(final HttpSecurity http) throws Exception {
- return http.csrf(csrf -> csrf.disable())
- .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
- .build();
- }
-}
diff --git a/src/main/java/org/patinanetwork/patchats/auth/AuthProperties.java b/src/main/java/org/patinanetwork/patchats/auth/AuthProperties.java
new file mode 100644
index 0000000..64f774e
--- /dev/null
+++ b/src/main/java/org/patinanetwork/patchats/auth/AuthProperties.java
@@ -0,0 +1,22 @@
+package org.patinanetwork.patchats.auth;
+
+import java.time.Duration;
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/** Auth configuration, bound from {@code app.auth.*}. */
+@ConfigurationProperties(prefix = "app.auth")
+@Getter
+@Setter
+public class AuthProperties {
+
+ /** Public origin of the SPA; magic links point at {@code /auth/verify?token=...}. */
+ private String baseUrl;
+
+ /** Whether the session cookie carries the {@code Secure} flag. Off only for plain-HTTP dev. */
+ private boolean cookieSecure = true;
+
+ /** How long an emailed magic link stays valid. */
+ private Duration magicLinkTtl = Duration.ofMinutes(15);
+}
diff --git a/src/main/java/org/patinanetwork/patchats/auth/TokenGenerator.java b/src/main/java/org/patinanetwork/patchats/auth/TokenGenerator.java
new file mode 100644
index 0000000..c449a62
--- /dev/null
+++ b/src/main/java/org/patinanetwork/patchats/auth/TokenGenerator.java
@@ -0,0 +1,41 @@
+package org.patinanetwork.patchats.auth;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Base64;
+import java.util.HexFormat;
+import org.springframework.stereotype.Component;
+
+/**
+ * Mints the opaque magic-link tokens. The raw token goes into the emailed link and is never persisted; only its SHA-256
+ * hex digest is stored, so a database leak cannot be replayed as a login.
+ */
+@Component
+public class TokenGenerator {
+
+ private static final int TOKEN_BYTES = 32;
+
+ private final SecureRandom secureRandom = new SecureRandom();
+
+ /** A freshly minted token: {@code raw} for the email link, {@code hash} for the database. */
+ public record GeneratedToken(String raw, String hash) {}
+
+ public GeneratedToken generate() {
+ final byte[] bytes = new byte[TOKEN_BYTES];
+ secureRandom.nextBytes(bytes);
+ final String raw = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ return new GeneratedToken(raw, hash(raw));
+ }
+
+ /** SHA-256 hex digest of a raw token, used to look up what the client presents. */
+ public static String hash(final String rawToken) {
+ try {
+ final MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ return HexFormat.of().formatHex(digest.digest(rawToken.getBytes(StandardCharsets.UTF_8)));
+ } catch (final NoSuchAlgorithmException ex) {
+ throw new IllegalStateException("SHA-256 is unavailable", ex);
+ }
+ }
+}
diff --git a/src/main/java/org/patinanetwork/patchats/auth/repo/MagicLinkTokenRepository.java b/src/main/java/org/patinanetwork/patchats/auth/repo/MagicLinkTokenRepository.java
new file mode 100644
index 0000000..2adda9e
--- /dev/null
+++ b/src/main/java/org/patinanetwork/patchats/auth/repo/MagicLinkTokenRepository.java
@@ -0,0 +1,52 @@
+package org.patinanetwork.patchats.auth.repo;
+
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Optional;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.springframework.jdbc.core.simple.JdbcClient;
+import org.springframework.stereotype.Repository;
+
+/** Plain-SQL access to {@code magic_link_tokens}. Only token hashes ever touch this table. */
+@Repository
+@RequiredArgsConstructor
+public class MagicLinkTokenRepository {
+
+ private final JdbcClient jdbc;
+
+ /** Invalidates every outstanding link for an email; called before issuing a new one. */
+ public void deleteByEmail(final String email) {
+ jdbc.sql("DELETE FROM magic_link_tokens WHERE email = :email")
+ .param("email", email)
+ .update();
+ }
+
+ public void insertToken(final UUID id, final String email, final String tokenHash, final Instant expiresAt) {
+ jdbc.sql("INSERT INTO magic_link_tokens (id, email, token_hash, expires_at)"
+ + " VALUES (:id, :email, :tokenHash, :expiresAt)")
+ .param("id", id)
+ .param("email", email)
+ .param("tokenHash", tokenHash)
+ .param("expiresAt", expiresAt.atOffset(ZoneOffset.UTC))
+ .update();
+ }
+
+ /**
+ * Atomically consumes an unexpired, unused token and returns the email it was issued to. The single UPDATE
+ * guarantees a token can only ever log in one caller, even under concurrent requests.
+ *
+ * Returns {@link Optional#empty()} for unknown, already-consumed, and expired tokens alike — the
+ * uniformity is deliberate: callers surface one generic "invalid or expired" outcome, so presenting tokens never
+ * becomes an oracle for which failure occurred or whether an email exists in the system.
+ */
+ public Optional consumeAndReturnEmail(final String tokenHash, final Instant now) {
+ return jdbc.sql("UPDATE magic_link_tokens SET consumed_at = :now"
+ + " WHERE token_hash = :tokenHash AND consumed_at IS NULL AND expires_at > :now"
+ + " RETURNING email")
+ .param("tokenHash", tokenHash)
+ .param("now", now.atOffset(ZoneOffset.UTC))
+ .query(String.class)
+ .optional();
+ }
+}
diff --git a/src/main/java/org/patinanetwork/patchats/common/config/ClockConfig.java b/src/main/java/org/patinanetwork/patchats/common/config/ClockConfig.java
new file mode 100644
index 0000000..83de3bc
--- /dev/null
+++ b/src/main/java/org/patinanetwork/patchats/common/config/ClockConfig.java
@@ -0,0 +1,15 @@
+package org.patinanetwork.patchats.common.config;
+
+import java.time.Clock;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/** Provides the single injectable {@link Clock} so services never call {@code Instant.now()} directly. */
+@Configuration
+public class ClockConfig {
+
+ @Bean
+ public Clock clock() {
+ return Clock.systemUTC();
+ }
+}
diff --git a/src/test/java/org/patinanetwork/patchats/auth/TokenGeneratorTest.java b/src/test/java/org/patinanetwork/patchats/auth/TokenGeneratorTest.java
new file mode 100644
index 0000000..f68a6d2
--- /dev/null
+++ b/src/test/java/org/patinanetwork/patchats/auth/TokenGeneratorTest.java
@@ -0,0 +1,42 @@
+package org.patinanetwork.patchats.auth;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import org.patinanetwork.patchats.auth.TokenGenerator.GeneratedToken;
+
+class TokenGeneratorTest {
+
+ private final TokenGenerator generator = new TokenGenerator();
+
+ @Test
+ void rawTokenIsUrlSafeAnd256Bits() {
+ final GeneratedToken token = generator.generate();
+
+ // 32 bytes base64url without padding -> 43 chars, no characters needing URL encoding.
+ assertEquals(43, token.raw().length());
+ assertTrue(token.raw().matches("[A-Za-z0-9_-]+"));
+ }
+
+ @Test
+ void hashMatchesSha256HexOfRaw() {
+ final GeneratedToken token = generator.generate();
+
+ assertEquals(TokenGenerator.hash(token.raw()), token.hash());
+ assertEquals(64, token.hash().length());
+ assertTrue(token.hash().matches("[0-9a-f]+"));
+ }
+
+ @Test
+ void generatedTokensAreUnique() {
+ assertNotEquals(generator.generate().raw(), generator.generate().raw());
+ }
+
+ @Test
+ void hashIsDeterministic() {
+ assertEquals(TokenGenerator.hash("abc"), TokenGenerator.hash("abc"));
+ assertNotEquals(TokenGenerator.hash("abc"), TokenGenerator.hash("abd"));
+ }
+}