Garden uses JWT-based stateless auth for API access. Access tokens are short-lived RS256 JWTs. Refresh tokens are opaque, stored hashed in Postgres, and rotated on every use.
POST /api/v1/auth/register
→ creates User (UNVERIFIED) + Identity (CREDENTIALS)
→ assigns CUSTOMER role
→ sends EMAIL_VERIFICATION token (24h TTL) via email
→ returns { accessToken, refreshToken }
POST /api/v1/auth/login
→ validates email + passwordHash
→ rejects if UserStatus == SUSPENDED
→ returns { accessToken, refreshToken }
GET /oauth2/authorization/google
→ Spring Security redirects to Google consent screen
GET /login/oauth2/code/google (Google callback)
→ OAuth2SuccessHandler runs:
→ upserts User (created or found by email)
→ upserts Identity (GOOGLE provider + accountId)
→ mints token pair
→ redirects to {FRONTEND_URL}/auth/callback?accessToken=...&refreshToken=...
GET /api/v1/auth/verify-email?token={rawToken}
→ validates TOKEN record (type=EMAIL_VERIFICATION, not expired)
→ sets User.emailVerifiedAt = now
→ sets User.status = ACTIVE
→ deletes token record (consumed)
POST /api/v1/auth/request-password-reset { email }
→ rate-limited: 1 request/min per email address
→ sends PASSWORD_RESET token (24h TTL) via email
→ response is always 204 (enumeration prevention)
POST /api/v1/auth/confirm-password-reset/{rawToken} { newPassword }
→ validates TOKEN record
→ updates passwordHash on Identity
→ deletes token record
Format: RS256-signed JWT (Nimbus JOSE + JWT)
TTL: 15 minutes
Claims:
| Claim | Value |
|---|---|
sub |
user UUID |
email |
user email |
permissions |
string array e.g. ["product:read","order:write"] |
impersonatedBy |
admin UUID (impersonation tokens only) |
The public key is injected at startup via app.jwt.public-key (base64 DER). Spring Security's OAuth2 resource server validates every request automatically.
Format: opaque UUID (raw value returned to client once)
Storage: SHA-256 hash stored in auth.refresh_tokens
TTL: 30 days
Every POST /api/v1/auth/refresh call:
- Validates the presented token hash exists and is not revoked
- Issues a new refresh token
- Sets
revokedAton the old token; setsreplacedByTokento the new raw value (for chain audit) - Returns the new token pair
If a revoked refresh token is presented (e.g., a stolen token used after it was already rotated):
- The entire token family is revoked (all tokens in the
replacedByTokenchain) - The attacker and the legitimate user are both logged out
- No silent failure
Used for email verification and password reset. Stored in auth.tokens.
| Type | TTL |
|---|---|
EMAIL_VERIFICATION |
24 hours |
PASSWORD_RESET |
24 hours |
All one-time tokens are:
- Generated as a UUID
- Stored as SHA-256 hash (raw value emailed to user)
- Deleted on successful consumption (
validateAndConsumeis atomic validate + delete)
User ──(many-to-many)──► Role ──(many-to-many)──► Permission
Permissions have a resource:action name (e.g., product:write, order:read).
These roles are seeded by Flyway migrations and cannot be deleted via the API:
| Role | Description |
|---|---|
CUSTOMER |
Default role assigned on registration. Storefront access only. |
STAFF |
Admin read access. Can view orders, products, users but not modify. |
MANAGER |
Full admin access. Cannot manage IAM (roles/permissions). |
OWNER |
Full access to everything including IAM. Gets all permissions implicitly. |
IamService.loadPermissionsForUser(userId) checks if the user has the OWNER role. If yes, all existing permissions are returned — no need to enumerate individual assignments. This is cached in Caffeine (10-minute TTL, max 1000 entries).
New roles can be created via POST /api/v1/admin/iam/roles. Permissions are assigned to roles, and roles are assigned to users via the admin user endpoints. The cache is evicted on every role assignment change.
Two custom annotations enforce auth at the controller method level:
@Authenticated — requires a valid JWT (any user). Applied to storefront endpoints that need identity but not a specific permission.
@HasPermission("resource:action") — requires a valid JWT where the permissions claim contains the named permission. Applied to all admin endpoints.
These are checked by Spring AOP / Spring Security. A valid JWT without the required permission returns 403 Forbidden.
Admin users (MANAGER or OWNER) can impersonate a customer for debugging:
POST /api/v1/admin/users/{targetUserId}/impersonate
→ validates caller has user:write permission
→ validates target is not STAFF/MANAGER/OWNER (staff cannot be impersonated)
→ creates ImpersonationToken (30 min TTL, SHA-256 hashed)
→ returns { impersonationToken, targetUserId }
The impersonation token is a special short-lived JWT with an additional impersonatedBy claim. ImpersonationTokenValidationFilter intercepts requests with this token and:
- Records
usedAton first use (single use) - Rejects if already used or expired
Impersonation is fully audited via the @Audited aspect.
- Passwords: bcrypt hashed via Spring Security
PasswordEncoder - SQL injection: not possible — all queries use JPA/Spring Data (parameterized)
- CORS: configured to allow the frontend origins only
- HTTPS: enforced at the reverse proxy level (Dokploy/Caddy);
server.forward-headers-strategy=frameworktrustsX-Forwarded-Proto - File upload: size capped at 20 MB per file, 25 MB per request
- Rate limiting: per-IP global; per-email for password reset
- Stripe webhook validation:
WebhookControllerverifies theStripe-Signatureheader using the webhook secret before processing any event