Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
687
Help us understand the problem. What is going on with this article?
@Hiro-mi

SPAのログイン認証のベストプラクティスがわからなかったのでわりと網羅的に研究してみた〜JWT or Session どっち?〜

More than 1 year has passed since last update.

SPAのログイン周りについて、「これがベストプラクティスだ!」という情報があまり見当たらないので、様々な可能性を模索してみました。
いろいろな状況が想定され、今回記載する内容に考慮の漏れや不備などがありましたら是非コメントでご指摘いただきたいです!特に「おすすめ度:○」と記載しているものに対しての批判をどしどしお待ちしております!
この記事でおすすめしているものであっても、ご自身の責任で十分な検討・検証の上で選択されてください。

前提

想定しているAPIは、

  • ログイン外のAPIにはPOST/PUT/DELETEのものがなく、GETのみ
  • GETのAPIにはDBを更新するなどの操作がない

とし、そのためログイン外ではCSRFを考慮しなくてよい、
という前提で話を進めます。

また、XSSに関しては常に対策は必要なのですが(フレームワーク側が自動的にしてくれる部分もある)、認証周りに関係すること以外はあまり記載していません。

Cookieについて

  • httpOnly属性: trueだと、JSからCookieを参照できなくなる。
  • secure属性: trueだと、httpsのときしかCookieを送信しない。
  • sameSite属性: クロスオリジンの際にCookieを送付するかどうかを3段階(strict/lax/none)で調整できる

XSSの際、悪意あるJSにCookieを抜き取られないようにするため、基本的にhttpOnly:trueにする。
また、http通信時にCookieが送信されてしまうと、通信内容を傍受されてしまった際にJWTが流出してしまう。
それを防ぐために基本的にはすべての通信をhttpsにして、secure:trueにする。
sameSite属性は場合による。(参考:https://numb86-tech.hatenablog.com/entry/2020/01/26/112607 )

早見表

方法 おすすめ度 コメント
JWTを用い、localStorageに保存 △〜✖ XSSでJWTが盗まれうる
JWTを用い、localStorageに保存 + refreshトークン △〜✖ XSSでJWTが盗まれうる。refreshトークンをCookieに保存する方法を他で聞いたことがないので不安。。
JWTを用い、Cookieに保存 + CSRFトークンを利用
JWTを用い、Cookieに保存 + CSRF対策にプリフライトリクエストを利用 Access-Control-Max-Ageの期間内だとCSRFがおきうる?
JWTを用い、Cookieに保存、JWT送信時はカスタムヘッダに付与して送信 △〜✖ httpOnly:trueを外さざるを得ないのでXSSでJWTが盗まれうる
JWTを用い、SameSiteCookieに保存 SPAとAPIが同一オリジンである必要がある
Sessionを用いる + CSRFトークンを利用 SPAでないアプリのAJAXでよくあるやつ
Sessionを用いる + CSRF対策にプリフライトリクエストを利用 Access-Control-Max-Ageの期間内だとCSRFがおきうる?
Sessionを用い、SessionのCookieはSameSiteCookie SPAとAPIが同一オリジンである必要がある

JWTを用い、localStorageに保存

概要

認証(mail + passwordなど)

JWTが返却される(レスポンスヘッダやレスポンスボディなどで返却。ここではCookieでの返却は考えない。)

JWTをlocalStorageに保存

リクエスト時はJWTを一緒に送信(リクエストヘッダやリクエストボディ)
ログイン有効時間をユーザーが最後に操作してから○○分以内という風にする場合は、リクエストの度にJWTを更新する必要がある。
そうでない場合はログイン時に設定した有効期限が来るとユーザー操作中でも強制ログアウト。

XSSについて

localStorageはOriginごとに独立しているため、別のOriginからJSでJWTを盗まれることはない。
しかしながらXSSが起きると、
同一Originの状況で悪意あるJSによってJWTが漏れてしまう。
たとえばnpmで導入した外部ライブラリの中に、JWTを抜き取るための悪意あるコードがないかどうかすべて確認するのは現実的ではないだろう。XSSは起きうるという前提で考えると、localStorageにJWTを保存するのは基本的にはよくない。
【参考】https://techracho.bpsinc.jp/hachi8833/2019_10_09/80851

CSRFについて

「不正なサイトから当該サイトにリクエストを投げたときに自動でcookieが付与される」という典型的なパターンの事象が起きないので考慮不要。

総括

JWTをlocalStorageに保存するだけの方法では、XSSによりJWTが抜き取られ、第三者によるリクエストが可能になる。特にJWTの有効期限が長い場合、被害が大きくなりうる。

有効期限が長く、リクエストの度にJWTを更新する場合

おすすめ度:✖
XSSでJWT流出の危険性

有効期限が長く、一度JWTを発行したらそのJWTを使い続ける場合(操作中でも有効期限がきたらログアウトさせる)

おすすめ度:✖
XSSでJWT流出の危険性

有効期限が十分に短い

おすすめ度:△
XSSでJWTが流出した場合の危険性をある程度抑えれれている。

JWTを用い、localStorageに保存 + refreshトークン

概要

例えば、JWTの有効期限を30分に設定するものの、refreshトークンの有効期限は1ヶ月、のような設定にする。
JWTの有効期限が切れてもrefreshトークンの有効期限内であれば自動的にrefresh、またrefreshする際にrefreshトークンの有効期限をその時点から1ヶ月になるように更新することで、1ヶ月に1度以上の頻度でユーザーが操作する限り、UX的には常にログインしているような状態を実現する。
refreshトークンがあるので、リクエストの度にJWTを更新することは考えない。
こうすれば、もしJWTが抜き取れれた場合もrefreshトークンが抜き取られなければ30分でJWTが使えなくなりある程度の安全性を確保できる。

認証(mail + passwordなど)

JWT(レスポンスヘッダやレスポンスボディなどで返却。ここではCookieでの返却は考えない。)

refreshトークン(Set-Cookie, httpOnly:true, secure:true)
を返却

JWTをlocalStorageに保存

リクエスト時は
JWT(リクエストヘッダやリクエストボディ)

refreshトークン(リクエストヘッダに自動的にCookieが付与される)
を一緒に送信
JWTの有効期限が切れていたらJWTとrefreshトークンで両者をrefresh

XSSについて

XSSによりJWTが抜き取られうる。refreshトークンはCookie(httpOnly: true)なので抜き取られない。

CSRFについて

JWTがCookieでないので基本的には考慮不要。

refreshトークンがCookieに存在するため、JWTが抜き取られた状態だとCSRFによって一応refreshすることができてしまう。
しかしながらCSRFでrefreshしてもCORS制限によりブラウザはレスポンスを読み取れず有効なJWTを利用することができないため問題ないと考えられる。

refreshトークンについて

JWTにはユーザー情報と、refreshトークンを第三者が復号できない方式で暗号化したもの、が含まれている。JWTの中身自体は第三者でも復号して確認できるので、refreshトークンの情報は、第三者が復号できない方法で暗号化したものを入れる必要があると考えた。
refresh時はサーバーサイドではJWTを検証してユーザー情報と暗号化されたrefreshトークンを取得し、
暗号化されたrefreshトークンを復号して(←サーバーでだけ復号できる)refreshトークンと一致しているかどうかを確認する。
確認が取れれば、JWTとrefreshトークンをrefreshする。

総括

JWTをlocalStorageに保存する方法では、XSSによりJWTが抜き取られ、第三者によるリクエストが可能になる。特にJWTの有効期限が長い場合、被害が大きくなりうる。
また、refreshトークンがcookieに存在するのでリクエストのたびに毎回送信されているのがすこし怖い気がする。
(2020.04.26 追記)↑に対して

cookie の path 属性を使って refresh 時にのみ送信する

という方法をコメントにて教えていただきました!ご指摘ありがとうございます!

JWTの有効期限が長い場合

おすすめ度:✖
XSSでJWT流出の危険性

JWTの有効期限が十分に短い

おすすめ度:△
XSSでJWTが流出した場合の危険性をある程度抑えれれている。
ただ、refreshトークンをCookieに保存する方法を他で聞いたことがない。この方法の危険性などありましたら是非指摘をお願いいたします!

JWTを用い、Cookieに保存 + CSRFトークンを利用

概要

認証(mail + passwordなど)

JWT(Set-Cookie, httpOnly:true, secure:true)

CSRFトークン(レスポンスヘッダやレスポンスボディなどで返却。Set-Cookieでの返却だとCSRF対策にならない。)
を返却

リクエスト時は、
JWT(リクエストヘッダに自動的にCookieが付与される)

CSRFトークン(リクエストヘッダやリクエストボディ)←POST/PUT/DELETEの場合に限定しても良いかもしれない
を送信
また、リロードした際にCSRFトークンが消えるのでJWTの有効期限が切れていない場合に再取得できる仕組みが必要。
さらに、ログイン有効時間をユーザーが最後に操作してから○○分以内という風にするにはリクエストの度にJWTを更新しなければならない。
そうでない場合はログイン時に設定した有効期限が来るとユーザー操作中でも強制ログアウト。

XSSについて

XSSが起きた場合でもhttpOnly:trueによりJWTが流出しない。
また、CSRFトークンもJSのグローバルから参照できない変数に保持していれば流出しない。

CSRFについて

悪意のあるサイトから当該サイトにリクエストを投げたとき、JWTはCookieとして自動で送信されるが、CSRFトークンは送信できず、サーバーサイド側で不正なリクエストと判断することができる。

※もしCSRF対策しなかった場合も、CORSの制約によりブラウザがレスポンスを読み取らせてくれず、ブラウザ側ではきちんとエラーがでる。ではなぜ対策が必要なのかと言うと、「レスポンスが読み取れないものの、サーバーサイドとしてはリクエストが正常に完了している」と言う点が問題だからである。
例えば、DBのレコード追加をするための通信の場合、レスポンスは読み取れないもののレコードの追加は完了してしまっている。

上記で記載した「POST/PUT/DELETEに限定する」というのは、リソースの更新を伴う機能にはGETを用いないという前提のもと、CSRFでアクセスされたとしてもリソースの更新はなく、CORS制約によりレスポンスが読み取られないため実害があまりないということによるものである。

※リロード時にCSRFトークン再取得をする機能があるので、悪意のあるサイトからCSRF的にCSRFトークン再取得を行ったとしてもレスポンスを読み取れないため問題なし。

CSRFトークンについて

このJWTにはユーザー情報と、CSRFトークンを第三者が復号できない方式で暗号化したもの、が含まれている。(JWTの中身自体は第三者でも復号して確認できるので、CSRFトークンの情報はさらに別の、復号できない方法で暗号化したものを入れる必要があると考えた)
サーバーサイドではJWTを検証してユーザー情報と暗号化されたCSRFトークンを取得し、
暗号化されたCSRFトークンを復号して(←サーバーでだけ復号できる)CSRFトークンと一致しているかどうかを確認する

総括

JWTをlocalStorageでなくCookieに保存するためXSSでJWTを奪われない。一方でCookieであるがゆえにCSRF対策が必要であり、CSRFトークンを用いている。

リクエストの度にJWTを更新する(ログイン有効時間をユーザーが最後に操作してから○○分以内とする)場合

おすすめ度:○

一度JWTを発行したらそのJWTを使い続ける場合(操作中でも有効期限がきたらログアウトさせる)場合

おすすめ度:○

類似実装

CSRFトークンをlocalStorageに保存する方法が下記で紹介されていた。
https://fujiten3.hatenablog.com/entry/2019/07/13/130459

こちらだとサーバーサイドでのトークンの管理をgemに一任し、フロント側ではCSRFトークンをlocalStorageで管理しているようだ。
こうなるとCSRFトークンがXSSで盗まれうるのだが、JWTの有効期限が短ければ頻繁にCSRFトークンがアップデートされるので危険性が低いと言えるかもしれない。また、Redisなども必要になるようだ。

JWTを用い、Cookieに保存 + CSRF対策にプリフライトリクエストを利用

概要

認証(mail + passwordなど)

JWT(Set-Cookie, httpOnly:true, secure:true)を返却

リクエスト時は、JWT(リクエストヘッダに自動的にCookieが付与される)とカスタムヘッダを送信

XSSについて

XSSが起きた場合でもhttpOnly:trueによりJWTが流出しない。

CSRFについて

Cookieを用いているため、CSRF対策が必要。
そこで、CORSによるプリフライトリクエストを利用してCSRFを防ぐ

プリフライトリクエストが起きるようにするための方法はいくつかあるが、カスタムヘッダを用いる方法がある。カスタムヘッダがあると、必ずプリフライトリクエストが起きる。サーバー側でどのOriginからのリクエストを許容するかをきちんと設定していれば、プリフライトリクエストで不正なリクエストが弾かれることになる。

また、カスタムヘッダがなければプリフライトリクエストが起こらないことがあるが、カスタムヘッダがないものはサーバー側で不正なリクエストとして扱えば良い。
参考:https://qiita.com/uryyyyyyy/items/9a8276f7241b650f1c15#%E3%83%96%E3%83%A9%E3%82%A6%E3%82%B6%E3%81%AEpre-flight%E3%82%92%E5%88%A9%E7%94%A8%E3%81%99%E3%82%8B

総括

JWTだとCSRF対策が不要なイメージがあるが、Cookieを使っているせいでこの方式では対策が必要である。
そこでプリフライトリクエストを用いて対策している。

リクエストの度にJWTを更新する(ログイン有効期限をユーザーが最後に操作してから○○分以内とする)場合

おすすめ度:△
Access-Control-Max-Ageの期間内だとCSRFがおきうる?

一度JWTを発行したらそのJWTを使い続ける場合(操作中でも有効期限がきたらログアウトさせる)場合

おすすめ度:△
Access-Control-Max-Ageの期間内だとCSRFがおきうる?

JWTを用い、Cookieに保存、 JWT送信時はカスタムヘッダに付与して送信

概要

認証(mail + passwordなど)

JWT(Set-Cookie, httpOnly: false, secure:true)を返却

リクエスト時は、JWT(リクエストヘッダのカスタムヘッダ)を送信
JWTはCookieで保存するものの手動でカスタムヘッダにセットリクエストを送信し、サーバーサイドではカスタムヘッダの方のJWTを検証することとする。

XSSについて

Cookieに保存しているJWTをJSで取得してカスタムヘッダに付け替える操作が必要であるため、httpOnly:trueを設定できない。
そのため、XSSでJWTが流出しうる。

CSRFについて

CookieのJWTを検証するわけではないのでCSRFは考慮しなくてよい。

総括

結局XSSでJWTが流出するのでlocalStorageに保存している場合と同じである。
有効期限を短くして、refreshトークンをhttpOnly:trueのCookieに設定するのであれば有用と思われる。

おすすめ度:△〜✖
理由に関しては、JWTをlocalStorageに入れるケースの説明を参照

JWTを用い、SameSiteCookieに保存

概要

SPAとAPIを同一Originにできる場合限定

認証(mail + passwordなど)

JWT(Set-Cookie, httpOnly:true, secure:true, SameSite: Strict)を返却

リクエスト時は、JWT(リクエストヘッダに自動的にCookieが付与される)を送信

XSSについて

XSSが起きた場合でもhttpOnly:trueによりJWTが流出しない。

CSRFについて

SameSiteCookieを用いているため、クロスオリジンだとCookieが送信されずCSRF対策は不要。

総括

すごくシンプルな実装でSPAとAPIが同一ドメインの場合はこれだけでいいかと思いました。
おすすめ度:○

Sessionを用いる + CSRFトークンを利用

概要

認証(mail + passwordなど)

SessionのCookie(Set-Cookie, httpOnly:true, secure:true)

CSRFトークン(レスポンスヘッダやレスポンスボディなどで返却。Set-Cookieでの返却だとCSRF対策にならない。)
を返却

リクエスト時は、
SessionのCookie(リクエストヘッダに自動的にCookieが付与される)

CSRFトークン(リクエストヘッダやリクエストボディ)←POST/PUT/DELETEの場合に限定しても良いかもしれない
を送信
また、リロードした際にCSRFトークンが消えるのでJWTの有効期限が切れていない場合に再取得できる仕組みが必要。

XSSについて

XSSが起きた場合でもhttpOnly:trueによりCookieが流出しない。
また、CSRFトークンもJSのグローバルから参照できない変数に保持していれば流出しない。

CSRFについて

悪意のあるサイトから当該サイトにリクエストを投げたとき、Cookieは自動で送信されるが、CSRFトークンは送信できず、サーバーサイド側で不正なリクエストと判断することができる。

※リロード時にCSRFトークン再取得をする機能が必要であるが、悪意のあるサイトからCSRF的にCSRFトークン再取得を行ったとしてもレスポンスを読み取れないため問題なし。

総括

SPAでないアプリケーションでAJAXするときのよくあるパターンのように、セッションを用いつつ、CSRFトークンで対策する
おすすめ度:○

Sessionを用いる + CSRF対策にプリフライトリクエストを利用

概要

認証(mail + passwordなど)

SessionのCookie(Set-Cookie, httpOnly:true, secure:true)を返却

リクエスト時は、
SessionのCookie(リクエストヘッダに自動的にCookieが付与される)とカスタムヘッダを送信

XSSについて

XSSが起きた場合でもhttpOnly:trueによりCookieが流出しない。

CSRFについて

プリフライトリクエストで対策する。
カスタムヘッダがあると、必ずプリフライトリクエストが起きる。サーバー側でどのOriginからのリクエストを許容するかをきちんと設定していれば、プリフライトリクエストで不正なリクエストが弾かれることになる。

また、カスタムヘッダがなければプリフライトリクエストが起こらないことがあるが、カスタムヘッダがないものはサーバー側で不正なリクエストとして扱えば良い。

総括

おすすめ度:△
Access-Control-Max-Ageの期間内だとCSRFがおきうる?

Sessionを用い、SessionのCookieはSameSiteCookie

概要

JWT in SameSiteCookieのSessionバージョン。
SPAとAPIを同一Originにできる場合限定

認証(mail + passwordなど)

SessionのCookie(Set-Cookie, httpOnly:true, secure:true, SameSite: Strict)を返却

リクエスト時は、SessionのCookie(リクエストヘッダに自動的にCookieが付与される)を送信

CSRFについて

SameSiteCookieを用いているため、クロスオリジンだとCookieが送信されずCSRF対策は不要。

XSSについて

XSSが起きた場合でもhttpOnly:trueによりCookieが流出しない。

総括

JWTのときと同様、すごくシンプルな実装でSPAとAPIが同一ドメインの場合はこれだけでいいかと思いました。
おすすめ度:○

今後の課題(自分用メモを兼ねる)

まとめ

認可のための情報はJSでアクセスできるところにおいてはいけない

JWTをWebStorageやhttpOnly:falseのCookieに置かない。SessionのCookieにはhttpOnlyをつける。

CSRF対策は、CSRFトークンを用いる or プリフライトリクエストを用いる or SameSiteCookie

CSRF対策で、本来改ざんできないはずのOriginを確認する方法があるようだが、場合によっては改ざん可能らしいので完璧ではないかもしれない。(参考:https://insert-script.blogspot.com/2018/05/adobe-reader-pdf-client-side-request.html
また、プリフライトリクエストを用いる方法に疑問視の声もある(参考:http://blog.a-way-out.net/blog/2015/03/23/stateless-csrf-protection/ )。
(2020.4.27追記)
プリフライトリクエストに関しては、Access-Control-Max-Ageの期限内だとCSRFが起きうる気がしてきました!

最後に Access-Control-Max-Age は、プリフライトリクエストを再び送らなくてもいいように、プリフライトのレスポンスをキャッシュしてよい時間を秒数で与えます。この例では86400秒、つまり24時間です。なお、ブラウザーは個々に内部の上限値を持っており、 Access-Control-Max-Age が上回った場合に制限を掛けます。

↑引用元(https://developer.mozilla.org/ja/docs/Web/HTTP/CORS)

SameSiteCookieについては実装例があまり多くなさそうな気がするが妥当性はどうだろうか。

687
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Hiro-mi
フリーランスエンジニアをしております。 Ruby on Rails/Spring Boot(Java)/React/Angularができます。

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
687
Help us understand the problem. What is going on with this article?