A 13-part series on the UmimaClean stack. 1 — AdonisJS · 2 — VineJS · 3 — Lucid · 4 — Inertia · 5 — Transformers · 6 — Transmit · 7 — Leaflet · 8 — Midtrans · 9 — Fonnte · 10 — ExcelJS · 11 — Frontend · 12 — Security · 13 — Testing
This part is four Adonis packages that together answer: who is this, are they allowed, how often, and where do the files go.
None of it is exotic. What makes it worth writing up is the handful of places where the obvious configuration is subtly wrong.
Identity Is a Phone Number
const AuthFinder = withAuthFinder(hash, {
uids: ['phone'],
passwordColumnName: 'password'
})
export default class User extends compose(UserSchema, AuthFinder) {
static rememberMeTokens = DbRememberMeTokensProvider.forModel(User)
}uids: ['phone']. No email column exists anywhere in the system — the reasoning is in Part 9, and it comes down to WhatsApp being where these customers actually are.
withAuthFinder is a mixin that adds verifyCredentials and hashes the password on assignment. That second part matters: assigning user.password = 'plaintext' hashes it. There is no way to accidentally store a plaintext password, because there is no code path that writes the column directly.
The guard is session-based:
const authConfig = defineConfig({
default: 'web',
guards: {
web: sessionGuard({
useRememberMeTokens: true,
rememberMeTokensAge: '30d',
provider: sessionUserProvider({
model: () => import('#models/user')
})
})
}
})Sessions, not JWTs. The frontend is Inertia, served from the same origin, so a cookie is the natural mechanism — and it comes with httpOnly, which means XSS cannot read the credential. A JWT in localStorage is readable by any script on the page, and the “stateless” advantage is worth nothing when the API and the frontend are the same deployment.
The Session Problem
{
cookieName: 'adonis-session',
age: '2h',
cookie: {
httpOnly: true,
secure: app.inProduction,
sameSite: 'lax',
},
store: 'cookie', // encrypted cookie, no server-side table
}Session data lives in an encrypted cookie. No sessions table, no Redis, nothing to clean up. Adonis encrypts and signs it with APP_KEY; tampering invalidates it.
sameSite: 'lax' allows the cookie on top-level navigations from other sites — which is what makes returning from an external payment page work — while blocking it on cross-site POSTs.
And here is the problem with cookie sessions: there is no server-side list of live sessions. Nothing to delete rows from.
Two situations demand exactly that:
- A staff member leaves and their account is deactivated. Their open session should stop working now, not in two hours.
- A customer resets their password because they think someone else has it. The sessions that prompted the reset must end.
With a database session store you delete the rows. With a cookie store there is nothing to delete.
The Fix: Compare Timestamps on the Way In
/**
* Two things end a session the browser still believes in. An account that
* has been deactivated — a staff member who has left — has to stop working
* on their next request rather than at the end of whatever they had open.
* And a session opened before the account's current password was set belongs
* to whoever knew the old password, which is exactly who a reset exists to
* lock out. Sessions are signed cookies, so there is no server-side table to
* delete them from; the comparison happens here instead, on the way in.
*/
private isStale(ctx: HttpContext): boolean {
const user = ctx.auth.user
if (!user || !user.isActive) {
return true
}
if (!user.passwordChangedAt) {
return false
}
const authenticatedAt = ctx.session.get(AUTHENTICATED_AT)
/**
* A session carrying no stamp predates the stamping. Treating it as stale
* asks those users to sign in once more; treating it as fresh would mean a
* password reset spared precisely the oldest sessions of all.
*/
if (typeof authenticatedAt !== 'string') {
return true
}
return DateTime.fromISO(authenticatedAt) < user.passwordChangedAt
}Every session records when it was authenticated:
private stampSession(session: Session): void {
session.put(AUTHENTICATED_AT, DateTime.now().toISO())
}Every account records when its password last changed. A session stamped before the current password was set belongs to whoever knew the old one.
Checked in AuthMiddleware on every authenticated request:
if (this.isStale(ctx)) {
await ctx.auth.use('web').logout()
ctx.session.flash('error', 'Sesi Anda sudah berakhir. Silakan masuk kembali.')
return ctx.response.redirect().toRoute('session.create')
}Revocable sessions with no session store. Two timestamps and a comparison.
The unstamped-session branch is the detail I would have got wrong. Sessions predating the stamping have no authenticated_at. Treating them as fresh means a password reset spares precisely the oldest sessions — the ones most likely to be on a device the user no longer has. Treating them as stale costs those users one extra login. That is not a close call, and it is exactly the kind of default that gets chosen carelessly.
Password Reset Closes Both Doors
/**
* A reset is what someone does when they think their account is not theirs
* alone any more. Leaving the sessions that prompted it signed in would make
* the whole exercise a formality: the remember-me tokens are deleted
* outright, and moving `passwordChangedAt` forward invalidates every cookie
* session stamped before this moment on its next request.
*/
async resetPassword(data: ResetPasswordData, phone: string): Promise<User> {
const user = await User.findByOrFail('phone', phone)
await user.merge({ password: data.password, passwordChangedAt: DateTime.now() }).save()
await this.revokeRememberMeTokens(user)
return user
}Both halves are needed. passwordChangedAt kills the cookie sessions. Remember-me tokens live in a database table and would otherwise let an invalidated session quietly sign itself back in on the next visit — the exact door the reset was meant to close.
async revokeRememberMeTokens(user: User): Promise<void> {
const tokens = await User.rememberMeTokens.all(user)
for (const token of tokens) {
await User.rememberMeTokens.delete(user, token.identifier)
}
}Login Tells You Nothing Extra
/**
* A deactivated account is refused with exactly the same error as a wrong
* password, and deliberately so: a distinct message would tell whoever is
* typing that the number belongs to a real account that has been switched
* off, which is more than a rejected login should give away.
*/
async login(data: LoginData, auth: Authenticator<Authenticators>, session: Session) {
const { phone, password, rememberMe } = data
const user = await User.verifyCredentials(phone, password)
if (!user.isActive) {
throw new authErrors.E_INVALID_CREDENTIALS('Invalid user credentials')
}
await auth.use('web').login(user, Boolean(rememberMe))
this.stampSession(session)
return user
}Same error, same wording. “This account has been deactivated” is a more helpful message and it confirms the number belongs to a real account — which, with sequential Indonesian mobile numbers, is worth something to someone enumerating.
verifyCredentials also does the timing work: it hashes even when no user is found, so a nonexistent number takes the same time as a wrong password.
Signup defaults hard:
/**
* Defaults to a customer, and only ever produces anything else when a caller
* that has already checked who is asking passes a role in — which is the
* admin account-creation path and nothing else.
*/
const user = await User.create({
...data,
role: role || Role.CUSTOMER,
isActive: true,
passwordChangedAt: DateTime.now()
})The public form does not pass a role and cannot. There is no public route to a privileged account — staff and admin accounts exist only via the admin area. Role escalation through the signup form is not defended against; it is structurally unavailable.
Shield: CSRF and Headers
csrf: {
enabled: true,
exceptRoutes: ['/transaction/callback'],
enableXsrfCookie: true,
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
},The exception is mandatory, not a shortcut. Midtrans posts to /transaction/callback from their servers (Part 8). They have no session and no CSRF token, and never will. Without the exemption every payment confirmation returns 403 — and the failure is invisible until an order never advances.
That endpoint is not unprotected. It is protected by a SHA-512 signature over the payload, which is the appropriate mechanism for a caller that has no session.
The lesson generalises: CSRF protects session-authenticated state changes. A request authenticated some other way needs that other way checked, and forcing it through CSRF just breaks it.
enableXsrfCookie: true exposes the token as a readable cookie for JS clients. Inertia’s <Form> handles it automatically, which is why no form in this app mentions a token.
xFrame: { enabled: true }, // X-Frame-Options: DENY — no clickjacking
csp: { enabled: false },CSP is off, and that is the weakest point in this part. A properly configured Content-Security-Policy is the strongest available defence against XSS. The reason it is off is Vite’s dev server and inline styles, which need nonces or hashes to work — real configuration work that I did not do.
Not a decision I would defend. Flagging it honestly beats presenting the config as complete.
Five Limiters, Keyed on Five Things
export const signupLimiter = limiter.define('signup', (ctx) => {
return limiter
.allowRequests(10)
.every('1 minute')
.blockFor('10 minute')
.usingKey(`signup:${ctx.request.ip()}`)
.limitExceeded(() => {
throw new errors.E_VALIDATION_ERROR([
{ field: 'form', message: 'Terlalu banyak percobaan pendaftaran. Silakan coba lagi nanti.' }
])
})
})| Limiter | Allowance | Key | Why that key |
|---|---|---|---|
signup | 10 / min, block 10 min | IP | Bulk account creation |
login | 5 / min, block 5 min | IP + phone | Credential stuffing |
forgot-password | 1 / 15 min, block 15 min | IP | Enumeration, and each send costs money |
reset-password | 5 / 15 min | IP + URL | Brute-forcing a signed link |
payment | 15 / 5 min, block 5 min | user id | Spraying across many orders |
midtransCharge | 5 / 15 min | order id | Hammering the gateway on one order |
The key is the design. Same technology, six different threat models.
login includes the submitted phone, so five failures against one account locks that account’s attempts rather than the whole IP — which matters when several staff share an office connection, and still stops credential stuffing against one target.
forgot-password is the strictest at one per fifteen minutes. Two reasons: enumeration becomes impractical, and each send consumes real WhatsApp quota that the shop pays for.
The two payment limiters answer different questions, and the comments say so:
/**
* The per-order limit below protects Midtrans from a loop on a single order.
* This protects the shop from someone spraying payment requests across many
* orders at once, which that limit cannot see: each individual order is well
* inside its own budget while the provider is being hammered all the same.
*/
export const paymentLimiter = limiter.define('payment', (ctx) => {
return limiter
.allowRequests(15)
.every('5 minutes')
.blockFor('5 minutes')
.usingKey(`payment:${ctx.auth.user?.id ?? ctx.request.ip()}`)
})And the per-order one is applied inside the service, not on the route:
/**
* Applied around the charge itself rather than on the route, because asking to
* pay usually returns the existing pending QR without contacting Midtrans at
* all — only a genuinely new charge should count against the limit.
*/
export const midtransChargeLimiter = limiter.use({
requests: 5,
duration: '15 minutes',
blockDuration: '15 minutes'
})On the route it would count page visits. A customer reloading the payment screen five times would be locked out of an order they have not been charged for once.
Rate limit the expensive operation, not the request that might trigger it.
Every limiter throws a validation error, so being limited shows up as a message on the form rather than a 429 page. The state lives in a rate_limits table — no Redis, consistent with the one-process-one-database design from Part 1.
Drive: Private Files, Signed URLs
const driveConfig = defineConfig({
default: env.get('DRIVE_DISK'),
services: {
fs: services.fs({
location: app.makePath('storage'),
serveFiles: true,
routeBasePath: '/uploads',
visibility: 'private'
})
}
})visibility: 'private' is the important line. Proof photos are pictures of customers’ front doors and their property. Public URLs would mean anyone with the link — or anyone guessing a filename — can fetch them.
Files are written with a random name and handed back as a signed URL:
const key = `${folder}/${randomUUID()}.${photo.extname}`
await drive.use().putStream(key, createReadStream(photo.tmpPath!))
return drive.use().getSignedUrl(key, { expiresIn: PHOTO_RETENTION }) // '90d'randomUUID() rather than the original filename: no collisions, no enumeration, no user-controlled path segment. The folder is one of pickup, delivery, inspection, cleaning, chosen by the server.
putStream streams rather than buffering — a 5 MB phone photo does not sit in memory.
Retention is deliberate and matched at both ends:
/**
* Ninety days is how long a dispute about a collection or a hand-over stays
* live in practice, and it is what the signed URLs handed out with those
* photos are already set to expire after — so past this point the file is not
* evidence of anything, it is a picture of a stranger's front door taking up
* disk. The audit trail itself is never touched: the action, who did it and
* when it happened stay exactly where they are, only the image goes.
*/
const PHOTO_RETENTION_DAYS = 90The audit trail outlives the images. Who did what and when is permanent. The photo is evidence with a shelf life.
The Mistake
order_actions.photo_path stores a signed URL, not a storage key.
It works. It is also wrong, and it is the kind of wrong that only surfaces later: the row cannot regenerate its own link, because the key is only recoverable by parsing the URL apart. Fine while retention never changes; a migration over historical rows the moment it does.
Store the key. Derive the URL. A signed URL is a view of a file, and I persisted the view.
Accounts That Cannot Be Deleted
The last layer is about history rather than access.
/**
* Orders and order actions both reference users with `RESTRICT`, so a
* customer who has ordered or a staff member who has worked cannot be
* removed — their name is part of the record of what happened. Deleting
* yourself is refused for the same reason as demoting yourself.
*/
async deleteUser(admin: User, id: number): Promise<User> {
const user = await this.getUser(id)
if (user.id === admin.id) {
throw new vineErrors.E_VALIDATION_ERROR([
{ field: 'id', message: 'Anda tidak dapat menghapus akun Anda sendiri.' },
])
}
if (await this.hasHistory(user)) {
throw new vineErrors.E_VALIDATION_ERROR([
{ field: 'id', message: 'Akun ini sudah memiliki riwayat pesanan dan tidak dapat dihapus.' },
])
}
await user.related('addresses').query().delete()
await user.delete()
return user
}Three protections stacked.
Admins cannot delete or demote themselves. A one-way door out of the admin area, and the last admin locking the business out of its own system is not a recoverable mistake.
History cannot be erased. Backed by RESTRICT foreign keys (Part 3) — the database refuses even if this check were bypassed. Deactivation is the answer instead:
if (user.id === admin.id && !isActive) {
throw new vineErrors.E_VALIDATION_ERROR([{ field: 'isActive', message: '...' }])
}Deactivation takes effect on the account’s next request, through the isStale check above. No waiting for a session to expire.
The UI knows in advance:
/**
* The ids of the accounts on this page that can no longer be deleted, so
* the list can disable the button instead of letting an admin find out by
* being refused.
*/
async getUndeletableIds(users: User[]): Promise<number[]>One query for the whole page, and the button renders disabled with an explanation. The service check is the guarantee; the disabled button is the courtesy. Either alone is wrong — the check alone gives an ugly error, the UI alone gives no protection.
What I Would Change
Enable CSP. The real gap in this part.
Store storage keys, not signed URLs. Discussed above.
crypto.timingSafeEqual for the webhook signature. === on a hash comparison is theoretically timing-attackable, and the fix is one line.
No audit log for admin actions. Order actions are attributed thoroughly. Admin actions — creating a user, changing a price, deactivating an account — are not. The reconciliation override is, because it touches money. The rest should be too.
No 2FA. For an admin account that can see every customer’s address and every payment, phone-plus-password is thin. TOTP for privileged roles would be proportionate.
What I would keep: passwordChangedAt as a revocation mechanism, per-threat rate limit keys, and RESTRICT everywhere. Those three are each a few lines, and each one closes a hole that would otherwise be found the hard way.
Next: Part 13 — Testing an Inertia App with Japa, the last part, where asserting on props beats parsing HTML.