Auth: OTP Reset, Invite Signup, and Token Rotation

Part of the Sanchayam series. Invite-Only Signup Sanchayam is a personal finance tool. There is no public registration. New users are added by invitation from an existing user. The first user is created via a /setup endpoint that is only active when the database has no users. An invitation is a row in the invitations table: CREATE TABLE invitations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), token_hash VARCHAR NOT NULL UNIQUE, label VARCHAR, email VARCHAR, expires_at TIMESTAMP NOT NULL, used_at TIMESTAMP, created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMP NOT NULL DEFAULT NOW() ); The token itself is never stored - only its SHA-256 hash. The invitation link sent by email contains the raw token. When the signup form is submitted, the backend hashes the submitted token and looks up the hash. If the row exists, is not yet used, and has not expired, signup proceeds and used_at is set. The raw token cannot be reconstructed from the hash, so a database leak does not expose valid invitation links. ...

April 14, 2026 · 4 min · 810 words · Sagar Nayak

JWT Refresh Queue Pattern

Part of the MediaBridge series. The Problem with Silent Token Refresh A 5-minute access token is short enough to be practical for security but creates a UX problem. When a page loads and fires several API calls simultaneously, the token might expire in the middle of that burst. Suddenly all five requests get a 401 TOKEN_EXPIRED at roughly the same time. The naive fix is to retry each 401 by refreshing the token. But if five requests all hit 401 and all try to refresh, you get five simultaneous calls to /auth/refresh. Refresh tokens are single-use. The first call succeeds and rotates the token. The next four present the now-used refresh token and get REFRESH_TOKEN_USED. Those requests fail and the user gets logged out, not because their session was invalid but because the client raced itself. ...

February 19, 2026 · 5 min · 934 words · Sagar Nayak

Storing Credentials Securely: AES-256-GCM and JWT

Part of the PurelyManage series. PurelyManage handles two categories of sensitive data: IMAP passwords submitted by users during migration, and the session tokens that keep sysadmins logged in. Neither can be stored carelessly. This post covers how both are handled and why each design decision was made the way it was. Encrypting IMAP Credentials at Rest The problem with storing passwords When a user submits IMAP credentials for a migration job, the backend needs to hold onto those credentials until the job actually runs, which might be minutes or hours later depending on the queue. They have to live in the database. The question is in what form. ...

December 3, 2025 · 7 min · 1313 words · Sagar Nayak

Security Setup in Spring Boot: CORS, JWT, and API Tiers

What CORS blocks non-whitelisted origins in AppSecurityConfig. JWT is validated for protected endpoints in JwtAuthenticationFilter using AuthenticationManagerJWT. API tiers restrict access even with a valid token; higher tiers grant access to more endpoints. Flow (request lifecycle): Incoming request → CORS applied → non-whitelisted origins blocked. Public/No-Auth endpoints bypass JWT checks. Protected endpoints → JWT extracted and validated. Tier authorization matched against requested API tier. On success, controller executes; on failure, error is routed to global handler. Why Prevent misuse from unwanted domains via strict CORS. Reject bad tokens: expired, blocked, reused/refresh-misuse. Enforce progression: open → tier 4 → tier 3 → tier 2 → tier 1 as business rules demand. How CORS configuration (AppSecurityConfig.corsConfigurationSource()): @Bean fun corsConfigurationSource(): CorsConfigurationSource { val configuration = CorsConfiguration() configuration.allowedOrigins = listOf( "http://localhost:3000", "https://yourdomain.com" ) configuration.allowedMethods = listOf("GET","POST","PUT","DELETE","OPTIONS","PATCH") configuration.allowedHeaders = listOf( "Authorization","Content-Type","X-Requested-With","Accept","Origin", KeywordsAndConstants.HEADER_TRACKING_ID, KeywordsAndConstants.HEADER_API_KEY, KeywordsAndConstants.HEADER_OTP, KeywordsAndConstants.HEADER_AUTH_TOKEN ) configuration.allowCredentials = true configuration.exposedHeaders = listOf( KeywordsAndConstants.HEADER_TRACKING_ID, KeywordsAndConstants.HEADER_API_TIER ) val source = UrlBasedCorsConfigurationSource() source.registerCorsConfiguration("/**", configuration) return source } Explanation: ...

September 18, 2025 · 3 min · 553 words · Sagar Nayak