We have been working on Social Auth components for Kit . These components handle OAuth flows where a user leaves your application, authenticates with a provider like Google or GitHub, and returns through a callback URL.
The redirect logic in these flows got messy quickly. After the OAuth callback, the controller needs to decide where to send the user. The browser's Referer header points to the OAuth provider's domain, not to the page the user was on before. The response.redirect().back() method cannot use it. So the controller ends up managing session keys manually, storing URLs before the redirect, pulling them after, handling the fallback case.
We initially added some helpers to @adonisjs/ally, since that is where the OAuth redirect happens. But the same problem shows up in login flows, form submissions, and anywhere you send a user through an intermediate step. It is not specific to OAuth. So we moved the solution into the HTTP server and the session package, where it can serve all of these cases.
Fixing the open redirect vulnerability
Before we get into the new APIs, there is something we discovered along the way that is worth addressing first.
The response.redirect().back() method reads the Referer header from the incoming request and redirects to that URL. It did not validate the header in any way. If the referrer pointed to an external domain, the redirect would follow it. This is a well-known class of vulnerability called an open redirect. An attacker who controls the Referer header can redirect your users to a phishing page or any other destination.
The method now validates the referrer's host against the request's own Host header. If the hosts do not match, the redirect uses a fallback URL instead. You can provide a custom fallback as an argument.
response.redirect().back('/dashboard')
For applications that operate across multiple domains, you can configure additional trusted hosts in config/app.ts.
import { defineConfig } from '@adonisjs/core/http'
export const http = defineConfig({
redirect: {
allowedHosts: [
'app.example.com',
'admin.example.com',
],
},
})
Referrers from hosts not in this list and not matching the current request's host will cause back() to use the fallback.
With that fixed, here is what we added.
Returning from third-party redirects
When a user goes through an OAuth flow or a payment gateway, the browser sets the Referer header to the external provider's domain on the return request. With the host validation in place, back() correctly rejects this referrer. But you still need a way to send the user back to where they were.
Before this change, you would handle it like this.
// Before redirecting to the OAuth provider
session.put('return_url', request.url())
response.redirect().toPath(oauthRedirectUrl)
// In the callback controller
const returnUrl = session.pull('return_url') || '/dashboard'
response.redirect().toPath(returnUrl)
This works when you control the redirect yourself, but it does not help with redirects that happen elsewhere. Several parts of the framework and its packages call back() internally. The validation exception handler redirects back to the form with errors. The CSRF protection handler redirects back after a token mismatch. These code paths do not know about your custom session key. They call back(), and if the referrer is from an external host, the user ends up on / instead of the page they were on.
For this to work reliably, the session-based previous URL has to be a first-class part of the redirect system, not something each controller manages on its own.
How it works
The session package now integrates with the redirect system. If you store a URL under the key redirect.previousUrl in the session, back() will use that value instead of the Referer header. The value is consumed on first use, so it does not persist beyond the next request.
// Before redirecting to the OAuth provider
session.put('redirect.previousUrl', '/settings')
response.redirect().toPath(oauthRedirectUrl)
// In the callback controller
response.redirect().back()
Every call to back() in the framework, in packages, and in your own code benefits from this automatically.
Returning to the right page after login
A user visits /billing, gets redirected to /login because they are not authenticated, logs in, and lands on /dashboard. The page they were trying to reach is lost. This is one of the most common redirect problems in web applications, and AdonisJS did not have a built-in solution for it.
The Adocasts team had a custom action for this. It parsed the referrer, validated the URL against the router, and returned the matched route. It is fine code, but it is the kind of thing that ends up duplicated across projects. We added framework-level support for two common patterns.
After a forced login
When the auth exception handler redirects an unauthenticated user to the login page, it now stores the URL the user was trying to access. This happens automatically for GET requests that matched a route and were not AJAX calls. No changes to your auth middleware are needed.
After successful authentication, toIntended() reads the stored URL from the session, removes it, and redirects. If no URL was stored, it uses the fallback you provide.
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'
export default class SessionController {
async store({ request, auth, response }: HttpContext) {
const { email, password } = request.only(['email', 'password'])
const user = await User.verifyCredentials(email, password)
await auth.use('web').login(user)
return response.redirect().toIntended('/dashboard')
}
}
Login from a public page
The other pattern is when a user clicks a login link from a page they are already browsing. They are on /products/42, they click "Login", and after authenticating they should return to /products/42.
The login link passes the current URL as a query parameter.
<a href="{{
route('auth.login', {}, { qs: { intended: request.url(true) } })
}}">
Login
</a>
The login page handler stores it in the session using session.setIntendedUrl(). The URL is validated before storage. Protocol-relative URLs, malformed input, and other open redirect vectors are rejected silently.
import type { HttpContext } from '@adonisjs/core/http'
export default class SessionController {
async show({ request, session, view }: HttpContext) {
const intended = request.input('intended')
if (intended) {
session.setIntendedUrl(intended)
}
return view.render('auth/login')
}
}
After login, toIntended() works the same way regardless of how the URL was stored.
Query string forwarding
Previously, every redirect that needed to carry over query parameters required an explicit withQs() call. You can now enable this as a default in your configuration.
import { defineConfig } from '@adonisjs/core/http'
export const http = defineConfig({
redirect: {
forwardQueryString: true,
},
})
When a specific redirect should not carry the query string, you can opt out.
response.redirect().withQs(false).toPath('/')
Conclusion
When every application ends up writing the same solution, it is time for the framework to own it.