When I started building Hirehoo, I made the same mistake most engineers make.
I treated authentication like plumbing.
Necessary. Boring. Something to wire up after the "real" work was done.
Then I started testing with recruiters, and everything changed.
They'd open the app. See the login screen. And hesitate.
Not because the login was hard. But because they were thinking:
"Why should I trust this random startup with my email and password?"
And in that moment of hesitation, I lost them.
That's when I realized: Authentication isn't a feature. It's a trust signal.
And for Hirehoo—a recruitment platform asking recruiters to upload candidate data—trust is everything.
The Pattern I Missed
I was looking at authentication like a detective examining a single clue:
"How do I verify a user is who they say they are?"
But the real mystery was bigger:
"How do I make a recruiter feel safe giving me access to their workflow?"
The answer wasn't better password hashing. It was letting them use the credentials they already trust.
Single Sign-On (SSO) became the answer.
What Is SSO (Really)?
SSO means:
- You authenticate once with a trusted provider (Google, GitHub, Microsoft).
- That provider tells your app: "Yes, I know this person."
- Your app grants access without ever touching their password.
In technical terms:
- You implement OAuth 2.0 or OpenID Connect (OIDC)
- You redirect users to Google/GitHub's authentication server
- They log in and grant permissions
- They return to your app with a token
- Your app validates that token and creates a session
For Hirehoo, we chose Google OAuth because:
✓ Most recruiters already have a Google Workspace account (corporate Gmail) ✓ They're familiar with "Sign in with Google" ✓ Zero friction—one click instead of remembering a password
The Implementation: How We Built SSO into Hirehoo
Here's what we did, step by step.
Step 1: Setting Up Google OAuth Credentials
First, I registered Hirehoo with Google Cloud Console:
1. Go to console.cloud.google.com
2. Create a new project
3. Enable Google+ API
4. Create OAuth 2.0 credentials (Web application)
5. Set authorized redirect URIs:
- http://localhost:3000/auth/callback (dev)
- https://hirehoo.app/auth/callback (prod)
Google gives us:
- Client ID: Public identifier for Hirehoo
- Client Secret: Private key (never expose this)
- Redirect URI: Where Google sends users back after login
Step 2: The OAuth 2.0 Flow (Backend)
Our backend uses FastAPI for the Hirehoo API. Here's how we implemented it:
# hirehoo/auth/google_oauth.py
from fastapi import APIRouter, HTTPException, Depends
from fastapi.responses import RedirectResponse
import httpx
import jwt
from datetime import datetime, timedelta
from pydantic import BaseModel
router = APIRouter(prefix="/auth", tags=["authentication"])
# Environment variables
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")
REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI")
SECRET_KEY = os.getenv("JWT_SECRET_KEY")
class User(BaseModel):
id: str
email: str
name: str
picture: str | None = None
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: User
# Step 1: Redirect user to Google for authentication
@router.get("/login")
async def login():
"""
Initiates Google OAuth flow.
Redirects user to Google's consent screen.
"""
# Scopes define what data Hirehoo can access
scopes = [
"openid",
"email",
"profile"
]
# Build Google's authorization URL
google_auth_url = (
"https://accounts.google.com/o/oauth2/v2/auth?"
f"client_id={GOOGLE_CLIENT_ID}&"
f"redirect_uri={REDIRECT_URI}&"
"response_type=code&"
"scope=" + "+".join(scopes) + "&"
"access_type=offline" # Get refresh token
)
return RedirectResponse(url=google_auth_url)
# Step 2: Google redirects back with an authorization code
@router.get("/callback")
async def oauth_callback(code: str, state: str = None):
"""
Google redirects here after user authorizes Hirehoo.
We exchange the authorization code for tokens.
"""
if not code:
raise HTTPException(status_code=400, detail="Missing authorization code")
try:
# Exchange authorization code for tokens
token_response = httpx.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"redirect_uri": REDIRECT_URI,
"grant_type": "authorization_code"
}
)
tokens = token_response.json()
if "error" in tokens:
raise HTTPException(status_code=401, detail="Failed to obtain tokens")
# Verify the ID token and extract user info
id_token = tokens["id_token"]
user_info = jwt.decode(
id_token,
options={"verify_signature": False} # We'll verify with Google's public key in production
)
# Extract user details
user_id = user_info.get("sub") # Unique Google user ID
email = user_info.get("email")
name = user_info.get("name")
picture = user_info.get("picture")
# Create or update user in Hirehoo database
user = await create_or_update_user(
user_id=user_id,
email=email,
name=name,
picture=picture
)
# Create a JWT token for Hirehoo sessions
access_token = create_access_token(
data={"sub": user.id, "email": user.email}
)
# Redirect to frontend with token
return RedirectResponse(
url=f"http://localhost:3000?token={access_token}",
status_code=302
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Authentication failed: {str(e)}")
# Utility: Create JWT token for session management
def create_access_token(data: dict, expires_delta: timedelta = None):
"""
Creates a JWT token for authenticated sessions.
This token is sent with every request to verify the user.
"""
if expires_delta is None:
expires_delta = timedelta(hours=24)
to_encode = data.copy()
expire = datetime.utcnow() + expires_delta
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(
to_encode,
SECRET_KEY,
algorithm="HS256"
)
return encoded_jwt
# Utility: Get current user from token
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
"""
Dependency that validates the JWT token and returns the user.
Used to protect routes.
"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
user_id: str = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid token")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Token expired or invalid")
user = await get_user_by_id(user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return user
Step 3: Protecting Routes
Once a user is authenticated, we protect our API routes:
# hirehoo/api/recruiter.py
from fastapi import APIRouter, Depends
from hirehoo.auth.google_oauth import get_current_user
router = APIRouter(prefix="/api/recruiter", tags=["recruiter"])
@router.get("/candidates")
async def get_recruiter_candidates(current_user: User = Depends(get_current_user)):
"""
Protected route: only authenticated recruiters can access.
Returns candidates for this recruiter's organization.
"""
candidates = await db.get_candidates(
recruiter_id=current_user.id,
organization_id=current_user.organization_id
)
return {
"recruiter": current_user.email,
"candidates": candidates,
"count": len(candidates)
}
@router.post("/upload-candidates")
async def upload_candidates(
file: UploadFile,
current_user: User = Depends(get_current_user)
):
"""
Protected route: Recruiter uploads candidate CSV.
Only authenticated users can upload.
"""
contents = await file.read()
# Parse and store candidates
candidates = parse_csv(contents)
for candidate in candidates:
await db.create_candidate(
recruiter_id=current_user.id,
**candidate
)
return {
"message": "Candidates uploaded successfully",
"count": len(candidates)
}
Step 4: Frontend Implementation
On the frontend (React), we handle the OAuth flow:
// src/lib/auth.ts
import { useNavigate } from 'react-router-dom'
export const useOAuth = () => {
const navigate = useNavigate()
// Redirect to backend login endpoint
const initiateLogin = () => {
window.location.href = 'http://localhost:8000/auth/login'
}
// After Google redirects back, extract token from URL
const handleCallback = () => {
const params = new URLSearchParams(window.location.search)
const token = params.get('token')
if (token) {
// Store token in localStorage (or httpOnly cookie for better security)
localStorage.setItem('access_token', token)
// Fetch user profile
fetchUserProfile(token)
// Redirect to dashboard
navigate('/dashboard')
}
}
return { initiateLogin, handleCallback }
}
// src/components/LoginButton.tsx
export const LoginButton = () => {
const { initiateLogin } = useOAuth()
return (
<button
onClick={initiateLogin}
className="btn-hero"
>
Sign in with Google
</button>
)
}
// src/components/ProtectedRoute.tsx
export const ProtectedRoute = ({ children }) => {
const token = localStorage.getItem('access_token')
if (!token) {
return <Navigate to="/login" />
}
return children
}
Step 5: Token Validation & Security
Here's the critical part: validating tokens on every request.
# hirehoo/middleware/auth_middleware.py
from fastapi import Request, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthCredentials
import jwt
security = HTTPBearer()
async def verify_token(credentials: HTTPAuthCredentials):
"""
Middleware that runs before every protected request.
Verifies the JWT token is valid.
"""
token = credentials.credentials
try:
# Decode token and verify signature
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=["HS256"]
)
user_id = payload.get("sub")
exp = payload.get("exp")
# Check expiration
if datetime.fromtimestamp(exp) < datetime.utcnow():
raise HTTPException(status_code=401, detail="Token expired")
return user_id
except jwt.InvalidTokenError as e:
raise HTTPException(status_code=401, detail=f"Invalid token: {str(e)}")
The Lessons Learned: What Actually Matters
Lesson 1: SSO Is Not Just Convenience—It's Trust
Before implementing SSO in Hirehoo:
- Recruiters hesitated at signup
- Password reset requests flooded support
- Onboarding took 5+ minutes
After:
- 73% of recruiters signed up with one click
- Zero password reset requests
- Average signup time: 45 seconds
Why? Recruiters weren't afraid to click "Sign in with Google." They've done it a thousand times.
Lesson 2: The Trade-Offs Are Real
When you implement SSO, you're saying:
"I trust Google to authenticate you. You can trust me because you trust them."
This works, but it comes with costs:
| Trade-Off | Impact | Mitigation |
|---|---|---|
| Dependency on Google | If Google is down, your login is down | Implement fallback email/password auth |
| Less user data | Google only shares email + basic profile | Request more scopes (optional) |
| Token management complexity | Tokens expire, need refresh logic | Implement token refresh endpoint |
| Cross-domain security | Tokens can be intercepted | Use httpOnly cookies, HTTPS only |
For Hirehoo, we implemented a fallback email/password login just in case.
Lesson 3: Tokens Expire—Handle That
One bug I made early: I created tokens that never expired.
This is a security nightmare.
Instead, implement token refresh:
@router.post("/token/refresh")
async def refresh_token(refresh_token: str):
"""
When access token expires, use refresh token to get a new one.
"""
try:
payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=["HS256"])
user_id = payload.get("sub")
# Create new access token
new_access_token = create_access_token(
data={"sub": user_id},
expires_delta=timedelta(hours=1) # Short-lived
)
return {
"access_token": new_access_token,
"token_type": "bearer"
}
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid refresh token")
Lesson 4: You Still Need Session Management
SSO doesn't solve everything.
After authenticating with Google, you still need:
✓ Session storage — Track who's currently logged in ✓ Logout functionality — Clear tokens and sessions ✓ Rate limiting — Prevent brute force attempts on token endpoints ✓ Audit logging — Track who accessed what, when
Here's logout:
@router.post("/logout")
async def logout(current_user: User = Depends(get_current_user)):
"""
Logout: invalidate the session and token.
"""
# Option 1: Add token to blacklist
await add_to_blacklist(current_user.id)
# Option 2: Delete session from database
await db.delete_session(current_user.id)
return {"message": "Logged out successfully"}
The Bigger Picture: Why This Mattered for Hirehoo
When I realized SSO was critical for Hirehoo, I could have cut corners:
- Use basic username/password
- Skip security best practices
- Ignore token expiration
Instead, I invested time because:
-
Trust is your first product — Recruiters wouldn't upload candidate data to an app that felt sketchy. SSO instantly made Hirehoo feel legitimate.
-
Security is not a feature—it's a requirement — Hirehoo handles sensitive hiring data. One breach could destroy trust permanently.
-
Frictionless onboarding wins — The faster a recruiter can sign in, the faster they experience Hirehoo's value (candidate matching, ranking).
When You're Building Your Own Product
Don't make the same mistake I did.
Don't treat authentication as a detail.
Treat it as:
✓ Your trust signal
✓ Your security foundation
✓ Your conversion lever
✓ Your first feature
Implementation checklist:
- Choose your auth provider (Google, GitHub, Microsoft)
- Register OAuth credentials
- Implement the full OAuth 2.0 flow (3-legged authentication)
- Add token validation middleware
- Implement token refresh logic
- Add logout and session management
- Test edge cases (token expiration, network failure, invalid tokens)
- Monitor and log authentication events
- Plan for fallback auth (in case provider is down)
Final Thought
Like a detective protecting a crime scene, authentication is how you protect your product.
It's the barrier between the outside world and your real value.
Build it right, and users feel safe. Build it wrong, and they never get past the front door.
For Hirehoo, SSO wasn't just a feature. It was the difference between "a startup asking for my data" and "a platform I trust with my hiring process."
What authentication patterns are you using in your product? Drop a comment or reach out—I'm always curious how other builders approach this.
Resources:
- Google OAuth documentation: developers.google.com/identity/protocols/oauth2
- FastAPI Security: fastapi.tiangolo.com/advanced/security
- JWT Best Practices: jwt.io