The CSRF check is the one stop on this trip that compares instead of routes: verify_authenticity_token runs as a before_action, reads the session's _csrf_token, unmasks the token from the form, and compares. It's session-bound, not page-bound, which is why the same masked token keeps working for the life of the session.
I lost a week to the flip side of that. I was scripting a POST and reading the token straight out of the raw HTML, and the comment form's authenticity_token is the literal string NOTHING until JS fills it in. So I was posting the word NOTHING and getting 422 Invalid authenticity token back, like my session was broken. It never was. The token has to come from the same session you POST with, and from a field that isn't a placeholder.
Thanks Kaj, good addition. CSRF is the one stop in the request path that compares instead of routes, and it is session-bound, so a placeholder token in the HTML will always fail. Useful tip for anyone scripting POST requests.
the tripwire is usually the order you do it in, not the token itself. scrape the form, sign in, then post, and the token is already dead: sign-in rotates the session (devise and most apps do it deliberately, for session fixation), and the _csrf_token lives in the session you just dropped. fetch the page after you're logged in, out of the same jar.
two more that hand you a 422 with a token that looks perfect:
per_form_csrf_tokens (on in plenty of upgraded apps): the token is scoped to that form's action and method, so the one from /articles/new is not the one for PATCH /articles/:id.
a token carried over from a jar you didn't keep sending. same string, different session, fails.
and before rewriting the scrape, read the server log for Can't verify CSRF token authenticity. The 422 body is empty by default, so it reads like a routing problem. The log line tells you which of the three you actually have.
Your request walkthrough would make a good debugging exercise: send one request that routing rejects, one that a controller rejects, and one that fails during the database query. Then trace how each response gets back out. I'd pair the first step with prachub's HTTP-router question. Explaining those three failures would tell me more than memorizing the names of every middleware layer.
Great idea, Emily. That would make a good exercise. Routing rejects raise RoutingError, a controller rejects with something like ParameterMissing (400), and a failed query raises RecordNotFound (404) or StatementInvalid (500). All three travel back out through the same middleware (ShowExceptions) and become the response. Tracing those paths teaches more than memorizing the middleware list. I'll consider adding it as a follow-up.
Thanks for spelling out the three paths. I’d finish the exercise with a small table showing where the exception started and which response the client actually saw. That gives me something concrete to compare with my prediction, instead of only copying a middleware diagram.
The view_assigns explanation is the best one I've read. I've explained Rails to juniors for years and always stumbled on "how does @article reach the view" — now I have the exact code path: instance_variable_set("@#{key}", value).
One thing that took me years to appreciate: the GeneratedAttributeMethods module isn't just clever — it's why article.title doesn't override your custom def title; "custom #{super}"; end. Most ORMs generate methods on the class itself, which means super doesn't work. Rails puts them in an included module specifically so super still finds the database column. That design choice is invisible until you need it, then it saves you.
Curious about your production story — have you ever hit the ActiveRecord::ConnectionTimeoutError due to a pool mismatch? Asking because that's the one most teams I've seen get wrong on first deploy.
Thanks Oleksandr, and great point about super. Because the generated methods live in an included module, you can override title and call super to reach the real column value. As for ConnectionTimeoutError, a pool smaller than Puma's max threads is one of the most common first-deploy mistakes. Keeping pool equal to RAILS_MAX_THREADS avoids it.
I'd add one caveat: overriding a generated method with super works, but it can break if the underlying column name changes. It's a powerful escape hatch, but it couples your override to the schema. Worth mentioning as a trade-off.
The CSRF check is the one stop on this trip that compares instead of routes:
verify_authenticity_tokenruns as a before_action, reads the session's_csrf_token, unmasks the token from the form, and compares. It's session-bound, not page-bound, which is why the same masked token keeps working for the life of the session.I lost a week to the flip side of that. I was scripting a POST and reading the token straight out of the raw HTML, and the comment form's
authenticity_tokenis the literal stringNOTHINGuntil JS fills it in. So I was posting the word NOTHING and getting422 Invalid authenticity tokenback, like my session was broken. It never was. The token has to come from the same session you POST with, and from a field that isn't a placeholder.Wrote the whole trap up here, in case it saves someone else the week: dev.to/kaj_ilands/your-scripted-po...
Thanks Kaj, good addition. CSRF is the one stop in the request path that compares instead of routes, and it is
session-bound, so a placeholder token in the HTML will always fail. Useful tip for anyone scripting POST requests.the tripwire is usually the order you do it in, not the token itself. scrape the form, sign in, then post, and the token is already dead: sign-in rotates the session (devise and most apps do it deliberately, for session fixation), and the
_csrf_tokenlives in the session you just dropped. fetch the page after you're logged in, out of the same jar.two more that hand you a 422 with a token that looks perfect:
per_form_csrf_tokens(on in plenty of upgraded apps): the token is scoped to that form's action and method, so the one from/articles/newis not the one forPATCH /articles/:id.and before rewriting the scrape, read the server log for
Can't verify CSRF token authenticity.The 422 body is empty by default, so it reads like a routing problem. The log line tells you which of the three you actually have.Your request walkthrough would make a good debugging exercise: send one request that routing rejects, one that a controller rejects, and one that fails during the database query. Then trace how each response gets back out. I'd pair the first step with prachub's HTTP-router question. Explaining those three failures would tell me more than memorizing the names of every middleware layer.
Great idea, Emily. That would make a good exercise. Routing rejects raise RoutingError, a controller rejects with something like
ParameterMissing (400), and a failed query raisesRecordNotFound (404)orStatementInvalid (500). All three travel back out through the same middleware(ShowExceptions)and become the response. Tracing those paths teaches more than memorizing the middleware list. I'll consider adding it as a follow-up.Thanks for spelling out the three paths. I’d finish the exercise with a small table showing where the exception started and which response the client actually saw. That gives me something concrete to compare with my prediction, instead of only copying a middleware diagram.
The
view_assignsexplanation is the best one I've read. I've explained Rails to juniors for years and always stumbled on "how does @article reach the view" — now I have the exact code path:instance_variable_set("@#{key}", value).One thing that took me years to appreciate: the
GeneratedAttributeMethodsmodule isn't just clever — it's whyarticle.titledoesn't override your customdef title; "custom #{super}"; end. Most ORMs generate methods on the class itself, which meanssuperdoesn't work. Rails puts them in an included module specifically sosuperstill finds the database column. That design choice is invisible until you need it, then it saves you.Curious about your production story — have you ever hit the
ActiveRecord::ConnectionTimeoutErrordue to a pool mismatch? Asking because that's the one most teams I've seen get wrong on first deploy.Thanks Oleksandr, and great point about super. Because the generated methods live in an included module, you can override title and call super to reach the real column value. As for
ConnectionTimeoutError, a pool smaller than Puma's max threads is one of the most common first-deploy mistakes. Keeping pool equal toRAILS_MAX_THREADSavoids it.I'd add one caveat: overriding a generated method with super works, but it can break if the underlying column name changes. It's a powerful escape hatch, but it couples your override to the schema. Worth mentioning as a trade-off.
Thank you. It was really helpful for ROR developer.
Thanks Joel, glad it helped!
SM Tahosin. This is my individual question.
How do you think of the US IT marketplace? Are you fine working as CEO in Bangladesh?