How to use JWTs with Magic
#Quick Start Instructions
01$ git clone https://github.com/magiclabs/example-jwt.git
02$ cd example-jwt
03$ mv .env.local.example .env.local // enter your Magic API keys
04$ yarn install
05$ yarn dev
#Introduction
When a user logs in with Magic, by default they'll remain authenticated for 7 days until having to login again. Some developers may want to take over session management from Magic by issuing session from their server, rather than relying on Magic's client-side sdk method isLoggedIn()
to tell if a user is authenticated. This tutorial will show how you can control the user's session length by issuing cookies and JSON web tokens (JWT) from your own server.
Note: the session length is now customizable by the developer through the Magic dashboard.
When relying on Magic to manage sessions, the standard flow is:
- User logs in with
loginWithMagicLink
- Send the auth token to your backend to validate
- On the frontend, call
magic.user.isLoggedIn()
to verify the user is authenticated
This tutorial will take over session management responsibilities from Magic, and the new flow will be:
- User logs in with
loginWithMagicLink
- Send the auth token to your backend to validate
- Create a JWT (containing the user info) and set it inside an
httpOnly
cookie - On the frontend, to verify the user is authenticated, send a request to your own backend at
/api/user
(instead of callingisLoggedIn()
), a route we'll set up to verify and refresh the cookie & JWT
Note: even though we’re relying on our backend to tell if the user is logged in, they will still be authenticated with Magic for 7 days after logging in (unless they explicitly logout before then).
With this approach, you can set the JWT and cookie to expire in 15 minutes, one month, or whatever is best for your app. And after the user logs in, since Magic is no longer relied upon, all we need to do is verify the cookie and JWT to know the user's session is valid.
#What are JSON Web Tokens
JWTs are a token standard that can be used as proof of identity, as well as what permissions a user has. Each JWT has three parts, a Header
, Payload
, and Signature
, separated by a .
. The Header
specifies the signing algorithm used to sign the token, the Payload
contains the data, such as name, email, role, expiration timestamp, etc, and the Signature
is generated by taking the Header and Payload, and signing it using the algorithm specified in the Header with a secret
value.
Example JWT:
01eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
02eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
03SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Servers issue JWTs to users, and verify them on subsequent requests back to the server. When created, JWTs are signed with a secret that only the issuing server knows, and when verifying the authenticity of the token, the server again uses that secret. If the JWT was altered in any way, when verifying the token, the signature will not match, so the server knows to reject the token.
It's important to note that information inside a JWT is not encrypted or secret. For example, anyone can enter a JWT into https://jwt.io to decode and read the contents.
#File Structure
01├── README.md
02├── components
03│ ├── email-form.js
04│ ├── header.js
05│ ├── layout.js
06│ └── loading.js
07├── lib
08│ ├── UserContext.js
09│ ├── cookies.js
10│ ├── magic.js
11│ └── magicAdmin.js
12├── package.json
13├── pages
14│ ├── _app.js
15│ ├── _document.js
16│ ├── api
17│ │ ├── login.js
18│ │ ├── logout.js
19│ │ └── user.js
20│ ├── index.js
21│ ├── login.js
22│ └── profile.js
23├── public (images)
24├── .env.local
25└── yarn.lock
#Login
#login.js (Client-side)
After clicking the magic link, loginWithMagicLink
resolves to an auth token, which is then sent to our backend at /api/login
. The server will respond with user data, which we set in the UserContext
.
01const Login = () => {
02 const [disabled, setDisabled] = useState(false);
03 const [user, setUser] = useContext(UserContext);
04
05 // Redirect logged in users to /profile if trying to visit login page
06 useEffect(() => {
07 user?.issuer && Router.push('/profile');
08 }, [user]);
09
10 async function handleLoginWithEmail(email) {
11 try {
12 setDisabled(true); // disable login button to prevent multiple emails from being triggered
13
14 // Trigger Magic link to be sent to user
15 let didToken = await magic.auth.loginWithMagicLink({ email });
16
17 // Validate didToken with server
18 const res = await fetch('/api/login', {
19 method: 'POST',
20 headers: {
21 'Content-Type': 'application/json',
22 Authorization: 'Bearer ' + didToken,
23 },
24 });
25
26 if (res.status === 200) {
27 let data = await res.json();
28 setUser(data.user);
29 Router.push('/profile');
30 }
31 } catch (error) {
32 setDisabled(false); // re-enable login button - user may have requested to edit their email
33 console.log(error);
34 }
35 }
36
37 return; // <LoginForm />
38};
#/api/login (Server-side)
In our /api/login
route, we first need to validate the auth token provided by Magic, and then use it to grab information about the user. That information is what will be stored inside the JWT payload, which itself will be stored inside a cookie, and automatically sent to our server on subsequent requests.
Example of the stored cookie:
01export default async function login(req, res) {
02 try {
03 const didToken = req.headers.authorization.substr(7);
04
05 await magic.token.validate(didToken);
06
07 const metadata = await magic.users.getMetadataByToken(didToken);
08
09 // Create JWT with information about the user, expires in `SESSION_LENGTH_IN_DAYS`, and signed by `JWT_SECRET`
10 let token = jwt.sign(
11 {
12 ...metadata,
13 exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * process.env.SESSION_LENGTH_IN_DAYS,
14 },
15 process.env.JWT_SECRET,
16 );
17
18 // Set a cookie containing the JWT
19 setTokenCookie(res, token);
20
21 res.status(200).send({ user: metadata });
22 } catch (error) {
23 console.log(error);
24 res.status(500).end();
25 }
26}
#Persisting Login
#Client-side
_app.js
is a file that initializes all of our pages, and is run when any new page is refreshed. Here, we send a request to our backend at /api/user
to check if a user is logged in. This backend route simply verifies the cookie and JWT, and refreshes the expiration of each. If /api/user
responds with no user, redirect the user to /login
.
01function MyApp({ Component, pageProps }) {
02 const [user, setUser] = useState();
03
04 // If JWT is valid, set the UserContext with returned value from /api/user
05 // Otherwise, redirect to /login and set UserContext to { user: null }
06 useEffect(() => {
07 setUser({ loading: true });
08 fetch('/api/user')
09 .then(res => res.json())
10 .then(data => {
11 data.user ? setUser(data.user) : Router.push('/login') && setUser({ user: null });
12 });
13 }, []);
14
15 return (
16 <UserContext.Provider value={[user, setUser]}>
17 <Layout>
18 <Component {...pageProps} />
19 </Layout>
20 </UserContext.Provider>
21 );
22}
23
24export default MyApp;
#Validating Cookie and JWT (Backend)
The /api/user
route will get a request any time a user refreshes the page. It verifies, then refreshes the JWT and cookie, and sends back the logged in user's data to our frontend. The purpose of refreshing the token is so that a user is not logged out after SESSION_LENGTH_IN_DAYS
days after first logging in, but only logged out after SESSION_LENGTH_IN_DAYS
days of inactivity.
01export default async function user(req, res) {
02 try {
03 if (!req.cookies.token) return res.json({ user: null });
04
05 let token = req.cookies.token;
06
07 let user = jwt.verify(token, process.env.JWT_SECRET);
08
09 // Refresh JWT
10 let newToken = jwt.sign(
11 {
12 ...user,
13 exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * process.env.SESSION_LENGTH_IN_DAYS,
14 },
15 process.env.JWT_SECRET,
16 );
17
18 setTokenCookie(res, newToken);
19
20 res.status(200).json({ user });
21 } catch (error) {
22 res.status(200).json({ user: null });
23 }
24}
#Profile.js
To display information about the user, we rely on the UserContext
. This is where we stored the user data collected from the response from /api/user
in _app.js
.
01const Profile = () => {
02 const [user] = useContext(UserContext);
03
04 return (
05 <>
06 {!user || user.loading ? (
07 <Loading />
08 ) : (
09 user.issuer && // <ProfileInfo />
10 )}
11 </>
12 );
13};
#Logging a User Out
#Logout
Note: Backing up to when a user logs in with loginWithMagicLink
, the sdk first checks if the user has an active session with Magic, and if so, automatically logs the user in without having to click any magic link.
To manually log a user out, this will require a request to our backend at /api/logout
to clear the cookie with the JWT that's being used to prove the user has a valid session.
Even though we are managing the session with the JWT, the session with Magic is still valid for 7 days after first logging in (unless the user logs out before then). Depending on how long ago the user logged in, their session with Magic could still be active. That’s why we need to attempt to log the user out with Magic as well. If the user’s session with Magic is expired, it will throw an error, so this logic is wrapped in a try / catch
block.
01// header.js
02<Link href="/api/logout">
03 <TextButton color="warning" size="sm">
04 Logout
05 </TextButton>
06</Link>
01// /api/logout
02export default async function logout(req, res) {
03 try {
04 if (!req.cookies.token) return res.status(401).json({ message: 'User is not logged in' });
05
06 let token = req.cookies.token;
07
08 let user = jwt.verify(token, process.env.JWT_SECRET);
09
10 removeTokenCookie(res);
11
12 try {
13 await magic.users.logoutByIssuer(user.issuer);
14 } catch (error) {
15 console.log('Users session with Magic already expired');
16 }
17
18 res.writeHead(302, { Location: '/login' });
19 res.end();
20 } catch (error) {
21 res.status(401).json({ message: 'User is not logged in' });
22 }
23}
Again, it's possible that the user is logged in with our app, but has an expired session with Magic. In order for certain Magic SDK methods to work (such as to update their email using magic.user.updateEmail
), the user must be logged in with Magic. So if needing to call one of these, make sure to prompt the user to login again with loginWithMagicLink
if their session with Magic has expired.
#Done
You now have a Next.js app with Magic authentication, and custom sessions!