Authentication & Authorization โ Frontpics Image Marketplace
Authentication Strategyโ
Tokens: JWT Access + httpOnly Refresh Cookieโ
โโโโโโโโโโโโ POST /auth/login โโโโโโโโโโโโโโโโ
โ Browser โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโบ โ NestJS API โ
โ โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ access_token (JWT, 15min, in body)
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh
โโโโโโโโโโโโ โโโโโโโโโโโโโโโโ
Access token (JWT)
- Payload:
{ sub: userId, email, role, iat, exp } - Signed with RS256 (RSA private key on server, public key for verification).
- Lifetime: 15 minutes.
- Stored in browser memory only (not localStorage โ avoids XSS theft).
- Sent as
Authorization: Bearer <token>header on every API request.
Refresh token
- Random 256-bit value, Base64URL-encoded.
- Stored as SHA-256 hash in Redis:
refresh:{userId}:{hash}โ{ userId, role, issuedAt }, TTL = 7 days. - Set as httpOnly, Secure, SameSite=Strict cookie on
/auth/refreshpath only. - Never readable by JavaScript โ immune to XSS.
- Rotated on every use (old token invalidated, new one issued).
Token Refresh Flowโ
Browser access token expires (401 response)
โ
โผ
POST /auth/refresh (cookie sent automatically by browser)
โ
โผ
API validates refresh cookie hash in Redis
โโ Valid โ delete old hash, generate new access + refresh token, return new access token
โโ Invalid / expired โ 401, browser redirects to /auth/login
Frontend interceptor in apps/web/src/lib/api-client.ts:
// Axios response interceptor
axiosInstance.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
original._retry = true;
const { data } = await axios.post('/api/v1/auth/refresh', {}, { withCredentials: true });
setAccessToken(data.accessToken); // stored in React context / Zustand
original.headers['Authorization'] = `Bearer ${data.accessToken}`;
return axiosInstance(original);
}
return Promise.reject(error);
},
);
Registration & Verification Flowโ
1. POST /auth/register { email, password, firstName, lastName, role: 'buyer' | 'photographer' }
2. API:
a. Check email uniqueness
b. bcrypt.hash(password, 12)
c. DB transaction:
- CREATE user (status = pending_verification)
- CREATE credit_wallet
- CREATE photographer_profile OR agent_profile
d. Generate email verification token (random 32 bytes โ hex, store hash in Redis, TTL 24h)
e. Send verification email via Nodemailer
3. User clicks link โ GET /auth/verify-email?token=xxx
4. API validates token hash in Redis, sets user.emailVerifiedAt, user.status = active
5. Redirect to login page
Password Reset Flowโ
1. POST /auth/forgot-password { email }
- Always return 200 (don't reveal if email exists)
- If user found: generate reset token (random 32 bytes), store hash in Redis (TTL 1h)
- Send email with link: https://frontpics.app/auth/reset-password?token=xxx
2. POST /auth/reset-password { token, newPassword }
- Validate token hash in Redis
- bcrypt.hash(newPassword, 12)
- UPDATE user.passwordHash
- DELETE all refresh tokens for this user in Redis (log out all sessions)
- DELETE reset token from Redis
Authorization: Role-Based Access Control (RBAC)โ
Rolesโ
| Role | Description |
|---|---|
buyer | Can browse, search, purchase images, access own wallet and orders |
photographer | Everything a buyer can do + upload images, manage own images, request payouts |
admin | Full platform access including all admin endpoints |
NestJS Guardsโ
// apps/api/src/modules/auth/guards/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
// apps/api/src/modules/auth/guards/roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
return requiredRoles.includes(user.role);
}
}
Applied globally:
// apps/api/src/app.module.ts
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard }, // All routes require auth by default
{ provide: APP_GUARD, useClass: RolesGuard },
];
Public routes use @Public() decorator to skip JwtAuthGuard.
Row-Level Access (Ownership Checks)โ
Guards handle role-level access. Ownership is verified in service methods:
// images.service.ts
async update(imageId: string, userId: string, dto: UpdateImageDto) {
const image = await this.db.image.findUniqueOrThrow({ where: { id: imageId } });
if (image.photographerId !== userId) {
throw new ForbiddenException('You do not own this image');
}
if (image.status !== 'draft' && image.status !== 'active') {
throw new BadRequestException('Cannot edit image in current status');
}
// ... proceed with update
}
Route Protection Matrixโ
Route Public Buyer Photographer Admin
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
GET /images โ โ โ โ
GET /images/:id โ โ โ โ
GET /images/mine โ โ โ
POST /images โ โ โ โ
POST /upload/presigned โ โ โ โ
POST /orders โ โ โ โ
GET /wallets/me โ โ โ โ
POST /payouts/request โ โ โ โ
GET /admin/* โ โ โ โ
Session Securityโ
Logoutโ
POST /auth/logout
- Delete refresh token hash from Redis (immediate invalidation)
- Clear the httpOnly cookie (Set-Cookie with expired date)
- Access token is still valid for up to 15 minutes (acceptable tradeoff for stateless JWT)
For sensitive operations (payout requests, account deletion), the API re-validates the user's password before proceeding, regardless of JWT validity.
Concurrent session controlโ
- In MVP: multiple sessions allowed (standard behavior).
- Redis key structure
refresh:{userId}:{tokenId}allows listing and revoking individual sessions per user. - Admin can wipe all sessions for a suspended user:
DEL refresh:{userId}:*.
Rate Limitingโ
Applied via Redis-backed middleware on auth endpoints:
| Endpoint | Limit | Window |
|---|---|---|
POST /auth/login | 10 attempts | 15 minutes per IP |
POST /auth/register | 5 attempts | 1 hour per IP |
POST /auth/forgot-password | 3 attempts | 1 hour per IP |
POST /auth/reset-password | 5 attempts | 1 hour per token |
| All other API routes | 300 requests | 1 minute per user ID |
// apps/api/src/common/middleware โ uses @nestjs/throttler with Redis store
ThrottlerModule.forRoot({
throttlers: [{ ttl: 60000, limit: 300 }],
storage: new ThrottlerStorageRedisService(redisClient),
});
Next.js Route Protectionโ
Middleware in apps/web/src/middleware.ts runs on the edge:
export function middleware(request: NextRequest) {
const token = request.cookies.get('access_token')?.value; // if stored in cookie for SSR
// OR: read from Authorization header for API calls
const pathname = request.nextUrl.pathname;
// Protect photographer routes
if (pathname.startsWith('/photographer') && !isPhotographer(token)) {
return NextResponse.redirect(new URL('/auth/login', request.url));
}
// Protect admin routes
if (pathname.startsWith('/admin') && !isAdmin(token)) {
return NextResponse.redirect(new URL('/auth/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/photographer/:path*', '/admin/:path*', '/dashboard/:path*'],
};
Note: Next.js middleware only provides a first-level UX check. The real enforcement is always on the API (NestJS guards). The middleware just prevents unnecessary round trips.
Security Checklistโ
| Risk | Mitigation |
|---|---|
| XSS token theft | Access token in memory only; refresh token in httpOnly cookie |
| CSRF on cookie | SameSite=Strict cookie; no cross-site requests possible |
| Password brute force | bcrypt work factor 12; rate limiting on login |
| Token replay after logout | Refresh token invalidated in Redis on logout |
| Account takeover via reset | Reset token is single-use; invalidated after use; all sessions wiped |
| SQL injection | Prisma parameterized queries; raw SQL only with Prisma.sql tagged template |
| Broken object-level auth | Ownership checks in every service method that mutates data |
| Mass assignment | All DTOs use Zod schemas with explicit pick(); controller never passes raw body |
| Secrets in code | All secrets via environment variables; .env in .gitignore |
| Sensitive data exposure | Passwords never returned in any response; passwordHash excluded in all selects |