Skip to main content

Authentication & Authorization โ€” Frontpics Image Marketplace


Authentication Strategyโ€‹

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” 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/refresh path 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โ€‹

RoleDescription
buyerCan browse, search, purchase images, access own wallet and orders
photographerEverything a buyer can do + upload images, manage own images, request payouts
adminFull 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:

EndpointLimitWindow
POST /auth/login10 attempts15 minutes per IP
POST /auth/register5 attempts1 hour per IP
POST /auth/forgot-password3 attempts1 hour per IP
POST /auth/reset-password5 attempts1 hour per token
All other API routes300 requests1 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โ€‹

RiskMitigation
XSS token theftAccess token in memory only; refresh token in httpOnly cookie
CSRF on cookieSameSite=Strict cookie; no cross-site requests possible
Password brute forcebcrypt work factor 12; rate limiting on login
Token replay after logoutRefresh token invalidated in Redis on logout
Account takeover via resetReset token is single-use; invalidated after use; all sessions wiped
SQL injectionPrisma parameterized queries; raw SQL only with Prisma.sql tagged template
Broken object-level authOwnership checks in every service method that mutates data
Mass assignmentAll DTOs use Zod schemas with explicit pick(); controller never passes raw body
Secrets in codeAll secrets via environment variables; .env in .gitignore
Sensitive data exposurePasswords never returned in any response; passwordHash excluded in all selects